diff --git a/Makefile b/Makefile index 91b27cca..4edd7fdc 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ COUNT ?= 5 LIMACTL ?= limactl LIMA_INSTANCE_PREFIX ?= facts -LIMA_GO_VERSION ?= 1.26.4 +LIMA_GO_VERSION ?= 1.26.5 LIMA_GO_SERIES ?= 1.26 LIMA_GOARCH ?= arm64 LIMA_CPUS ?= 6 diff --git a/go.mod b/go.mod index d75ff200..e7ea7de7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ncode/facts -go 1.26.4 +go 1.26.5 require gopkg.in/yaml.v3 v3.0.1 diff --git a/internal/app/app.go b/internal/app/app.go index d4ad4531..dd7797bf 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -249,24 +249,19 @@ func runQuery(stdout, stderr io.Writer, args []string) error { if err != nil { return err } - facts := snapshot.Facts() + presentation := snapshot.OutputProjection(mergeDottedFacts) resolutionDuration := time.Since(resolutionStart).Seconds() out, err := engine.BuildFormatter(engine.FormatOptions{ - JSON: options.JSON, - YAML: options.YAML, - HOCON: options.HOCON, - IncludeTypedDotted: mergeDottedFacts, - Colorize: colorOutput, - }).Format(facts) + JSON: options.JSON, + YAML: options.YAML, + HOCON: options.HOCON, + Colorize: colorOutput, + })(presentation) if err != nil { return err } if options.Timing { - for _, fact := range facts { - name := fact.UserQuery - if name == "" { - name = fact.Name - } + for _, name := range presentation.PresentationNames() { if _, err := fmt.Fprintf(stdout, "fact '%s', took: (%.3f) seconds\n", name, resolutionDuration); err != nil { return err } @@ -283,7 +278,7 @@ func runQuery(stdout, stderr io.Writer, args []string) error { } } if options.Strict { - missing := engine.NewProjection(facts, mergeDottedFacts).MissingQueries(facts) + missing := presentation.MissingQueries() if len(missing) > 0 { for _, name := range missing { writeError(stderr, fmt.Sprintf("fact %q does not exist.", name), colorDiagnostics) @@ -392,15 +387,15 @@ func canUseVersionQueryFastPath(queries, externalDirs []string, disabledFacts ma } func writeVersionQuery(stdout io.Writer, jsonOutput, yamlOutput, hoconOutput bool) error { - facts := []engine.ResolvedFact{{Name: "facterversion", Value: engine.Version, UserQuery: "facterversion"}} + presentation := engine.NewProjection([]engine.ResolvedFact{{Name: "facterversion", Value: engine.Version, UserQuery: "facterversion"}}, false) // The fast path carries only the three format booleans: it deliberately - // ignores --color and --force-dot-resolution, so Colorize/IncludeTypedDotted - // stay false and formatter selection reuses the engine's own precedence. + // ignores --color and --force-dot-resolution, so it uses an uncolored, + // non-force-dot Projection and the engine's own formatter precedence. out, err := engine.BuildFormatter(engine.FormatOptions{ JSON: jsonOutput, YAML: yamlOutput, HOCON: hoconOutput, - }).Format(facts) + })(presentation) if err != nil { return err } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index def7ac26..77bc574b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -782,6 +782,49 @@ func TestRun_timingPrintsResolutionDuration(t *testing.T) { } } +func TestRun_sharedPresentationProjectionPreservesTimingOutputAndStrictOrder(t *testing.T) { + var stdout, stderr bytes.Buffer + + err := Run(&stdout, &stderr, []string{"--timing", "--strict", "--json", "os.name", "missing_fact", "missing_fact"}) + status, ok := err.(ExitStatus) + if !ok { + t.Fatalf("Run() err = %T %[1]v, want ExitStatus", err) + } + if status.Code() != 1 { + t.Fatalf("Run() status = %d, want 1", status.Code()) + } + + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + if len(lines) < 4 { + t.Fatalf("stdout = %q, want three timing lines followed by JSON", stdout.String()) + } + for i, name := range []string{"os.name", "missing_fact", "missing_fact"} { + want := regexp.MustCompile(`^fact '` + regexp.QuoteMeta(name) + `', took: \([0-9]+\.[0-9]{3}\) seconds$`) + if !want.MatchString(lines[i]) { + t.Fatalf("timing line %d = %q, want query %q", i, lines[i], name) + } + } + formatted := strings.Join(lines[3:], "\n") + var got map[string]any + if err := json.Unmarshal([]byte(formatted), &got); err != nil { + t.Fatalf("formatted output after timing lines is not JSON: %q: %v", formatted, err) + } + if got["os.name"] == nil { + t.Fatalf("formatted output = %q, want resolved os.name", formatted) + } + if value, exists := got["missing_fact"]; !exists || value != nil { + t.Fatalf("formatted output = %q, want missing_fact null", formatted) + } + if strings.Index(formatted, `"missing_fact"`) > strings.Index(formatted, `"os.name"`) { + t.Fatalf("formatted output order = %q, want missing_fact before os.name", formatted) + } + wantStderr := "ERROR Facts - fact \"missing_fact\" does not exist.\n" + + "ERROR Facts - fact \"missing_fact\" does not exist.\n" + if got := stderr.String(); got != wantStderr { + t.Fatalf("stderr = %q, want %q", got, wantStderr) + } +} + func TestRun_rejectsRemovedCustomFactOptions(t *testing.T) { tests := []struct { name string diff --git a/internal/engine/augeas.go b/internal/engine/augeas.go index 6d02be23..113fe836 100644 --- a/internal/engine/augeas.go +++ b/internal/engine/augeas.go @@ -20,10 +20,6 @@ func parseAugeasVersion(out string) string { return augeasVersionPattern.FindString(out) } -func augeasFacts(out string) []ResolvedFact { - return augeasVersionFacts(parseAugeasVersion(out)) -} - // augeasVersionFacts returns the augeas fact, or nothing when no augparse // binary produced a version: Ruby Facter omits the fact instead of emitting // an empty version string. diff --git a/internal/engine/augeas_test.go b/internal/engine/augeas_test.go index 3bfe8cec..e65b5819 100644 --- a/internal/engine/augeas_test.go +++ b/internal/engine/augeas_test.go @@ -6,19 +6,13 @@ import ( "testing" ) -func TestAugeasFacts_returnsStructuredVersion(t *testing.T) { - got := augeasFacts("augparse 1.14.1 ") +func TestAugeasVersionFacts_returnsParsedVersion(t *testing.T) { + got := augeasVersionFacts(parseAugeasVersion("augparse 1.14.1 ")) want := []ResolvedFact{ {Name: "augeas.version", Value: "1.14.1"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("augeasFacts() = %#v, want %#v", got, want) - } -} - -func TestAugeasFacts_skipsMissingVersion(t *testing.T) { - if got := augeasFacts(""); got != nil { - t.Fatalf("augeasFacts() = %#v, want nil", got) + t.Fatalf("augeasVersionFacts(parseAugeasVersion()) = %#v, want %#v", got, want) } } diff --git a/internal/engine/core.go b/internal/engine/core.go index 1f93e862..735ad8b9 100644 --- a/internal/engine/core.go +++ b/internal/engine/core.go @@ -157,28 +157,10 @@ func missingFileReader(string) ([]byte, error) { return nil, os.ErrNotExist } -func rootedPath(root, path string) string { - if root == "/" { - return "/" + strings.TrimPrefix(path, "/") - } - return filepath.Join(root, path) -} - func (s *Session) commandOutput(name string, args ...string) string { return s.host.run(s.ctx, name, args...) } -func bytesToMB(value any) any { - number, ok := numericValue(value) - if !ok { - return nil - } - if number <= 0 { - return 0.0 - } - return number / (1024.0 * 1024.0) -} - func bytesToHumanReadable(value any) any { number, original, ok := byteValue(value) if !ok { diff --git a/internal/engine/core_benchmark_test.go b/internal/engine/core_benchmark_test.go index bbe8f7a9..badc9881 100644 --- a/internal/engine/core_benchmark_test.go +++ b/internal/engine/core_benchmark_test.go @@ -113,7 +113,6 @@ func BenchmarkParseMacOSSystemProfilerHardware(b *testing.B) { func BenchmarkUnitFormatting(b *testing.B) { b.ReportAllocs() for b.Loop() { - _ = bytesToMB(256_586_343) _ = bytesToHumanReadable(1_048_575) _ = hertzToHumanReadable(2_365_000_000) } diff --git a/internal/engine/core_test.go b/internal/engine/core_test.go index 320e649b..4e9219e9 100644 --- a/internal/engine/core_test.go +++ b/internal/engine/core_test.go @@ -80,15 +80,7 @@ func TestPathEntries_splitsAndDropsEmpty(t *testing.T) { } } -func TestRootedPathAndIsSymlinkHelpers(t *testing.T) { - if got, want := rootedPath("/", "var/cache"), "/var/cache"; got != want { - t.Fatalf("rootedPath(/) = %q, want %q", got, want) - } - root := t.TempDir() - if got, want := rootedPath(root, "var/cache"), filepath.Join(root, "var/cache"); got != want { - t.Fatalf("rootedPath(temp) = %q, want %q", got, want) - } - +func TestIsSymlinkHelper(t *testing.T) { lstat := func(path string) (os.FileInfo, error) { switch path { case "/link": diff --git a/internal/engine/dmi.go b/internal/engine/dmi.go index df09d298..269a561e 100644 --- a/internal/engine/dmi.go +++ b/internal/engine/dmi.go @@ -188,13 +188,6 @@ func freeBSDDMIFacts(values map[string]string) []ResolvedFact { return []ResolvedFact{{Name: "dmi", Value: dmi}} } -func dragonFlyDMIFacts(values map[string]string, biosOutput, systemOutput, chassisOutput string) []ResolvedFact { - if facts := freeBSDDMIFacts(values); len(facts) > 0 { - return facts - } - return dragonFlyDMIDecodeFacts(biosOutput, systemOutput, chassisOutput) -} - func dragonFlyDMIDecodeFacts(biosOutput, systemOutput, chassisOutput string) []ResolvedFact { biosValues := parseColonValues(biosOutput) systemValues := parseColonValues(systemOutput) diff --git a/internal/engine/dmi_test.go b/internal/engine/dmi_test.go index b5f73e81..3d964903 100644 --- a/internal/engine/dmi_test.go +++ b/internal/engine/dmi_test.go @@ -274,7 +274,9 @@ func TestFreeBSDDMIFacts_returnsStructuredFacts(t *testing.T) { } } -func TestDragonFlyDMIFacts_fallsBackToDMIDecodeWhenKenvHasNoSMBIOS(t *testing.T) { +func TestCurrentDragonFlyDMIFactsFallsBackToDMIDecodeWhenKenvHasNoSMBIOS(t *testing.T) { + t.Parallel() + bios := `BIOS Information Vendor: SeaBIOS Version: 1.16.3-debian-1.16.3-2 @@ -295,7 +297,19 @@ func TestDragonFlyDMIFacts_fallsBackToDMIDecodeWhenKenvHasNoSMBIOS(t *testing.T) Asset Tag: Not Specified ` - facts := dragonFlyDMIFacts(map[string]string{}, bios, system, chassis) + host := &fakeHostOS{ + platform: "dragonfly", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("/usr/local/sbin/dmidecode", "-t", "bios"): bios, + fakeRunKey("/usr/local/sbin/dmidecode", "-t", "system"): system, + fakeRunKey("/usr/local/sbin/dmidecode", "-t", "chassis"): chassis, + }, + } + s := NewSessionContext(t.Context()) + s.host = host + + facts := currentDragonFlyDMIFacts(s) collection := Collection(facts) want := map[string]any{ @@ -318,29 +332,19 @@ func TestDragonFlyDMIFacts_fallsBackToDMIDecodeWhenKenvHasNoSMBIOS(t *testing.T) }, } if !reflect.DeepEqual(collection, want) { - t.Fatalf("dragonFlyDMIFacts() = %#v, want %#v", collection, want) - } -} - -func TestDragonFlyDMIFactsPrefersKenvSMBIOSValues(t *testing.T) { - values := map[string]string{ - "smbios.system.maker": "DragonFly Maker", - "smbios.system.product": "DragonFly Product", + t.Fatalf("currentDragonFlyDMIFacts() = %#v, want %#v", collection, want) } - - facts := dragonFlyDMIFacts(values, "Vendor: dmidecode BIOS", "Manufacturer: dmidecode", "Type: Other") - collection := Collection(facts) - - want := map[string]any{ - "dmi": map[string]any{ - "manufacturer": "DragonFly Maker", - "product": map[string]any{ - "name": "DragonFly Product", - }, - }, + wantCalls := make([]fakeHostRunCall, 0, len(freeBSDDMIKeys)+3) + for _, key := range freeBSDDMIKeys { + wantCalls = append(wantCalls, fakeHostRunCall{name: "kenv", args: []string{key}}) } - if !reflect.DeepEqual(collection, want) { - t.Fatalf("dragonFlyDMIFacts() = %#v, want %#v", collection, want) + wantCalls = append(wantCalls, + fakeHostRunCall{name: "/usr/local/sbin/dmidecode", args: []string{"-t", "bios"}}, + fakeHostRunCall{name: "/usr/local/sbin/dmidecode", args: []string{"-t", "system"}}, + fakeHostRunCall{name: "/usr/local/sbin/dmidecode", args: []string{"-t", "chassis"}}, + ) + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("currentDragonFlyDMIFacts() run calls = %#v, want %#v", host.runCalls, wantCalls) } } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 9f775248..1a8865e9 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -245,7 +245,7 @@ func (e *Engine) Discover(ctx context.Context, queries ...string) (*Snapshot, er facts = FilterDisabledFacts(facts, plan.disabledFacts) if len(plan.queries) > 0 { - facts = NewProjection(facts, plan.includeTypedDotted).Select(plan.queries) + facts = NewProjection(facts, plan.includeTypedDotted).selectFacts(plan.queries) } if plan.useCache && ctx.Err() == nil { @@ -258,7 +258,7 @@ func (e *Engine) Discover(ctx context.Context, queries ...string) (*Snapshot, er } facts = append(remaining, cached...) if len(plan.queries) > 0 { - facts = NewProjection(facts, plan.includeTypedDotted).Select(plan.queries) + facts = NewProjection(facts, plan.includeTypedDotted).selectFacts(plan.queries) } } diff --git a/internal/engine/factsutil.go b/internal/engine/factsutil.go index 13feed74..11eb9ddd 100644 --- a/internal/engine/factsutil.go +++ b/internal/engine/factsutil.go @@ -2,7 +2,6 @@ package engine import ( "fmt" - "strconv" "strings" ) @@ -73,41 +72,6 @@ func releaseHashFromString(version string, includePatch bool) map[string]any { return release } -func releaseHashFromMatchData(match []string) map[string]any { - if len(match) < 2 { - return nil - } - return releaseHashFromString(match[1], false) -} - -func tryToBool(value string) any { - switch value { - case "true": - return true - case "false": - return false - default: - return value - } -} - -func tryToInt(value any) any { - switch v := value.(type) { - case int: - return v - case float64: - return int(v) - case string: - parsed, err := strconv.Atoi(v) - if err != nil { - return v - } - return parsed - default: - return value - } -} - func deepStringifyKeys(value any) any { switch v := value.(type) { case map[any]any: diff --git a/internal/engine/factsutil_test.go b/internal/engine/factsutil_test.go index 16e4769f..05c04a06 100644 --- a/internal/engine/factsutil_test.go +++ b/internal/engine/factsutil_test.go @@ -2,7 +2,6 @@ package engine import ( "reflect" - "regexp" "testing" ) @@ -101,84 +100,6 @@ func TestFirstNonEmptyReturnsFirstValueInOrder(t *testing.T) { } } -func TestReleaseHashFromMatchData_matchesRubyFactsUtils(t *testing.T) { - releasePattern := regexp.MustCompile(`^RELEASE=(\d+.\d+.*)`) - majorPattern := regexp.MustCompile(`^RELEASE=(\d+)`) - - tests := []struct { - name string - match []string - want map[string]any - wantNil bool - }{ - {name: "major minor", match: releasePattern.FindStringSubmatch("RELEASE=4.3"), want: map[string]any{"full": "4.3", "major": "4", "minor": "3"}}, - {name: "major only", match: majorPattern.FindStringSubmatch("RELEASE=4"), want: map[string]any{"full": "4", "major": "4"}}, - {name: "nil", match: nil, wantNil: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := releaseHashFromMatchData(tt.match) - if tt.wantNil { - if got != nil { - t.Fatalf("releaseHashFromMatchData() = %#v, want nil", got) - } - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Fatalf("releaseHashFromMatchData() = %#v, want %#v", got, tt.want) - } - }) - } -} - -func TestTryToBool_matchesRubyUtils(t *testing.T) { - tests := []struct { - name string - input string - want any - }{ - {name: "true", input: "true", want: true}, - {name: "false", input: "false", want: false}, - {name: "unchanged", input: "something else", want: "something else"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tryToBool(tt.input); got != tt.want { - t.Fatalf("tryToBool(%q) = %#v, want %#v", tt.input, got, tt.want) - } - }) - } -} - -func TestTryToInt_matchesRubyUtils(t *testing.T) { - tests := []struct { - name string - input any - want any - }{ - {name: "int", input: 7, want: 7}, - {name: "string int", input: "7", want: 7}, - {name: "positive string int", input: "+7", want: 7}, - {name: "negative string int", input: "-7", want: -7}, - {name: "float", input: 7.10, want: 7}, - {name: "non numeric string", input: "string", want: "string"}, - {name: "partial string int", input: "7string", want: "7string"}, - {name: "true", input: true, want: true}, - {name: "false", input: false, want: false}, - {name: "string float", input: "7.10", want: "7.10"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tryToInt(tt.input); got != tt.want { - t.Fatalf("tryToInt(%#v) = %#v, want %#v", tt.input, got, tt.want) - } - }) - } -} - func TestDeepStringifyKeys_matchesRubyUtils(t *testing.T) { input := map[any]any{ "this": map[any]any{ diff --git a/internal/engine/formatter.go b/internal/engine/formatter.go index aeb43909..c969108a 100644 --- a/internal/engine/formatter.go +++ b/internal/engine/formatter.go @@ -9,57 +9,36 @@ import ( "strings" ) -// FormatOptions selects the presentation format for resolved facts. +// FormatOptions selects the presentation format for a Projection. type FormatOptions struct { - JSON bool - YAML bool - HOCON bool - IncludeTypedDotted bool - Colorize bool -} - -// Formatter renders resolved facts in one presentation format. -type Formatter interface { - Name() string - Format([]ResolvedFact) (string, error) -} - -type formatterFunc struct { - name string - format func([]ResolvedFact) (string, error) -} - -func (f formatterFunc) Name() string { return f.name } - -func (f formatterFunc) Format(facts []ResolvedFact) (string, error) { - return f.format(facts) + JSON bool + YAML bool + HOCON bool + Colorize bool } // BuildFormatter selects a formatter using Ruby's formatter factory precedence. -func BuildFormatter(opts FormatOptions) Formatter { +func BuildFormatter(opts FormatOptions) func(*Projection) (string, error) { switch { case opts.JSON: - return formatterFunc{name: "json", format: func(facts []ResolvedFact) (string, error) { - return FormatJSONWithDottedFacts(facts, opts.IncludeTypedDotted) - }} + return FormatJSON case opts.YAML: - return formatterFunc{name: "yaml", format: func(facts []ResolvedFact) (string, error) { - return FormatYAMLWithDottedFacts(facts, opts.IncludeTypedDotted), nil - }} + return func(projection *Projection) (string, error) { + return FormatYAML(projection), nil + } case opts.HOCON: - return formatterFunc{name: "hocon", format: func(facts []ResolvedFact) (string, error) { - return FormatHOCONWithDottedFacts(facts, opts.IncludeTypedDotted), nil - }} + return func(projection *Projection) (string, error) { + return FormatHOCON(projection), nil + } default: - return formatterFunc{name: "legacy", format: func(facts []ResolvedFact) (string, error) { - return FormatLegacyColored(facts, opts.IncludeTypedDotted, opts.Colorize), nil - }} + return func(projection *Projection) (string, error) { + return FormatLegacyColored(projection, opts.Colorize), nil + } } } -// FormatJSONWithDottedFacts renders JSON and optionally merges dotted custom and external facts. -func FormatJSONWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) (string, error) { - projection := NewProjection(facts, includeTypedDotted) +// FormatJSON renders a Projection using Facter's JSON presentation contract. +func FormatJSON(projection *Projection) (string, error) { value := any(projection.MultiQueryValues()) if projection.Shape() == ShapeFullTree { value = projection.FullTree() @@ -72,9 +51,8 @@ func FormatJSONWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) (s return string(out), nil } -// FormatYAMLWithDottedFacts renders YAML and optionally merges dotted custom and external facts. -func FormatYAMLWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) string { - projection := NewProjection(facts, includeTypedDotted) +// FormatYAML renders a Projection using Facter's YAML presentation contract. +func FormatYAML(projection *Projection) string { value := any(projection.MultiQueryValues()) if projection.Shape() == ShapeFullTree { value = projection.FullTree() @@ -86,9 +64,8 @@ func FormatYAMLWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) st return out + "\n" } -// FormatHOCONWithDottedFacts renders HOCON and optionally merges dotted custom and external facts. -func FormatHOCONWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) string { - projection := NewProjection(facts, includeTypedDotted) +// FormatHOCON renders a Projection using Facter's HOCON presentation contract. +func FormatHOCON(projection *Projection) string { switch projection.Shape() { case ShapeEmpty: return "" @@ -110,8 +87,7 @@ func FormatHOCONWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) s // key in an ANSI color chosen by its nesting depth. The rendering replicates // Ruby Facter's LegacyFactFormatter byte for byte: pretty-printed JSON rewritten // through Ruby's exact transform pipeline, quirks included. -func FormatLegacyColored(facts []ResolvedFact, includeTypedDotted, colorize bool) string { - projection := NewProjection(facts, includeTypedDotted) +func FormatLegacyColored(projection *Projection, colorize bool) string { switch projection.Shape() { case ShapeEmpty: return "" @@ -131,6 +107,11 @@ func FormatLegacyColored(facts []ResolvedFact, includeTypedDotted, colorize bool } } +// FormatLegacy renders a Projection using the uncolored legacy presentation. +func FormatLegacy(projection *Projection) string { + return FormatLegacyColored(projection, false) +} + func yamlLines(value any, depth int) []string { indent := strings.Repeat(" ", depth) switch v := value.(type) { @@ -451,27 +432,6 @@ func needsQuotedYAMLString(value string) bool { return false } -func uniqueQueries(facts []ResolvedFact) []string { - seen := make(map[string]bool, len(facts)) - queries := make([]string, 0, len(facts)) - for _, fact := range facts { - if seen[fact.UserQuery] { - continue - } - seen[fact.UserQuery] = true - queries = append(queries, fact.UserQuery) - } - return queries -} - -func factsForQueries(facts []ResolvedFact) map[string]any { - values := make(map[string]any, len(facts)) - for _, fact := range facts { - values[fact.UserQuery] = ValueForQuery(fact) - } - return values -} - // legacyKeyPalette cycles per nesting depth when key coloring is enabled: // cyan, yellow, green, magenta, blue. var legacyKeyPalette = [...]string{"\x1b[36m", "\x1b[33m", "\x1b[32m", "\x1b[35m", "\x1b[34m"} diff --git a/internal/engine/formatter_benchmark_test.go b/internal/engine/formatter_benchmark_test.go index e3d6bb80..e85b632f 100644 --- a/internal/engine/formatter_benchmark_test.go +++ b/internal/engine/formatter_benchmark_test.go @@ -11,10 +11,11 @@ func BenchmarkFormatJSON(b *testing.B) { {Name: "processors.models", Value: []string{"Apple M4 Pro"}}, {Name: "networking.hostname", Value: "host"}, } + projection := NewProjection(facts, false) b.ReportAllocs() for b.Loop() { - if _, err := FormatJSON(facts); err != nil { + if _, err := FormatJSON(projection); err != nil { b.Fatal(err) } } @@ -29,10 +30,11 @@ func BenchmarkFormatLegacy(b *testing.B) { {Name: "processors.models", Value: []string{"Apple M4 Pro"}}, {Name: "networking.hostname", Value: "host"}, } + projection := NewProjection(facts, false) b.ReportAllocs() for b.Loop() { - _ = FormatLegacy(facts) + _ = FormatLegacy(projection) } } @@ -45,9 +47,10 @@ func BenchmarkFormatHOCON(b *testing.B) { {Name: "processors.models", Value: []string{"Apple M4 Pro"}}, {Name: "networking.hostname", Value: "host"}, } + projection := NewProjection(facts, false) b.ReportAllocs() for b.Loop() { - _ = FormatHOCON(facts) + _ = FormatHOCON(projection) } } diff --git a/internal/engine/formatter_helpers_test.go b/internal/engine/formatter_helpers_test.go deleted file mode 100644 index e5883ae4..00000000 --- a/internal/engine/formatter_helpers_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package engine - -// These four zero-flag formatter helpers had one production caller — the -// version fast path — which now routes through BuildFormatter. They survive -// only as terse test conveniences over the *WithDottedFacts/*Colored variants, -// so the production build no longer exports them. - -// FormatJSON renders facts using Facter's JSON presentation contract. -func FormatJSON(facts []ResolvedFact) (string, error) { - return FormatJSONWithDottedFacts(facts, false) -} - -// FormatYAML renders facts using Facter's YAML presentation contract. -func FormatYAML(facts []ResolvedFact) string { - return FormatYAMLWithDottedFacts(facts, false) -} - -// FormatHOCON renders facts using Facter's HOCON presentation contract. -func FormatHOCON(facts []ResolvedFact) string { - return FormatHOCONWithDottedFacts(facts, false) -} - -// FormatLegacy renders facts using the original key => value text format. -func FormatLegacy(facts []ResolvedFact) string { - return FormatLegacyColored(facts, false, false) -} diff --git a/internal/engine/formatter_test.go b/internal/engine/formatter_test.go index 052d6063..0f398ef1 100644 --- a/internal/engine/formatter_test.go +++ b/internal/engine/formatter_test.go @@ -8,31 +8,35 @@ import ( func TestBuildFormatterMatchesRubyFormatterFactory(t *testing.T) { t.Parallel() + facts := []ResolvedFact{{Name: "kernel", Value: "Darwin"}} tests := []struct { name string opts FormatOptions want string }{ - {name: "json", opts: FormatOptions{JSON: true}, want: "json"}, - {name: "yaml", opts: FormatOptions{YAML: true}, want: "yaml"}, - {name: "hocon", opts: FormatOptions{HOCON: true}, want: "hocon"}, - {name: "legacy", opts: FormatOptions{}, want: "legacy"}, - {name: "json preferred over yaml", opts: FormatOptions{JSON: true, YAML: true}, want: "json"}, + {name: "json", opts: FormatOptions{JSON: true}, want: "{\n \"kernel\": \"Darwin\"\n}"}, + {name: "yaml", opts: FormatOptions{YAML: true}, want: "kernel: \"Darwin\"\n"}, + {name: "hocon", opts: FormatOptions{HOCON: true}, want: "kernel=Darwin\n"}, + {name: "legacy", opts: FormatOptions{}, want: "kernel => Darwin"}, + {name: "json preferred over yaml", opts: FormatOptions{JSON: true, YAML: true}, want: "{\n \"kernel\": \"Darwin\"\n}"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := BuildFormatter(tt.opts) - if got.Name() != tt.want { - t.Fatalf("BuildFormatter(%#v).Name() = %q, want %q", tt.opts, got.Name(), tt.want) + got, err := BuildFormatter(tt.opts)(NewProjection(facts, false)) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("BuildFormatter(%#v)() = %q, want %q", tt.opts, got, tt.want) } }) } } -func TestBuildFormatterWiresDottedAndColorOptions(t *testing.T) { +func TestBuildFormatterConsumesProjectionAndColorOptions(t *testing.T) { facts := []ResolvedFact{ {Name: "os.name", Value: "Darwin"}, {Name: "site.role", Value: "web", Type: "external"}, @@ -40,52 +44,53 @@ func TestBuildFormatterWiresDottedAndColorOptions(t *testing.T) { tests := []struct { name string opts FormatOptions - want func([]ResolvedFact) (string, error) + want func(*Projection) (string, error) }{ { name: "json dotted", - opts: FormatOptions{JSON: true, IncludeTypedDotted: true}, - want: func(facts []ResolvedFact) (string, error) { - return FormatJSONWithDottedFacts(facts, true) + opts: FormatOptions{JSON: true}, + want: func(projection *Projection) (string, error) { + return FormatJSON(projection) }, }, { name: "yaml dotted", - opts: FormatOptions{YAML: true, IncludeTypedDotted: true}, - want: func(facts []ResolvedFact) (string, error) { - return FormatYAMLWithDottedFacts(facts, true), nil + opts: FormatOptions{YAML: true}, + want: func(projection *Projection) (string, error) { + return FormatYAML(projection), nil }, }, { name: "hocon dotted", - opts: FormatOptions{HOCON: true, IncludeTypedDotted: true}, - want: func(facts []ResolvedFact) (string, error) { - return FormatHOCONWithDottedFacts(facts, true), nil + opts: FormatOptions{HOCON: true}, + want: func(projection *Projection) (string, error) { + return FormatHOCON(projection), nil }, }, { name: "legacy dotted color", - opts: FormatOptions{IncludeTypedDotted: true, Colorize: true}, - want: func(facts []ResolvedFact) (string, error) { - return FormatLegacyColored(facts, true, true), nil + opts: FormatOptions{Colorize: true}, + want: func(projection *Projection) (string, error) { + return FormatLegacyColored(projection, true), nil }, }, { name: "legacy plain", opts: FormatOptions{}, - want: func(facts []ResolvedFact) (string, error) { - return FormatLegacyColored(facts, false, false), nil + want: func(projection *Projection) (string, error) { + return FormatLegacy(projection), nil }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := BuildFormatter(tt.opts).Format(facts) + projection := NewProjection(facts, true) + got, err := BuildFormatter(tt.opts)(projection) if err != nil { t.Fatal(err) } - want, err := tt.want(facts) + want, err := tt.want(projection) if err != nil { t.Fatal(err) } @@ -105,20 +110,21 @@ func TestBuildFormatterMachineFormatsIgnoreColorize(t *testing.T) { name string opts FormatOptions }{ - {name: "json", opts: FormatOptions{JSON: true, IncludeTypedDotted: true}}, - {name: "yaml", opts: FormatOptions{YAML: true, IncludeTypedDotted: true}}, - {name: "hocon", opts: FormatOptions{HOCON: true, IncludeTypedDotted: true}}, + {name: "json", opts: FormatOptions{JSON: true}}, + {name: "yaml", opts: FormatOptions{YAML: true}}, + {name: "hocon", opts: FormatOptions{HOCON: true}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - plain, err := BuildFormatter(tt.opts).Format(facts) + projection := NewProjection(facts, true) + plain, err := BuildFormatter(tt.opts)(projection) if err != nil { t.Fatal(err) } colorizedOpts := tt.opts colorizedOpts.Colorize = true - colorized, err := BuildFormatter(colorizedOpts).Format(facts) + colorized, err := BuildFormatter(colorizedOpts)(projection) if err != nil { t.Fatal(err) } @@ -209,7 +215,7 @@ func TestFormatJSON_noUserQueryBuildsStructuredFacts(t *testing.T) { {Name: "os.architecture", Value: "x86_64"}, } - got, err := FormatJSON(facts) + got, err := FormatJSON(NewProjection(facts, false)) if err != nil { t.Fatal(err) } @@ -225,7 +231,7 @@ func TestFormatJSON_userQueriesUseOriginalQueryKeys(t *testing.T) { {Name: "os.family", Value: "Darwin", UserQuery: "os.family"}, } - got, err := FormatJSON(facts) + got, err := FormatJSON(NewProjection(facts, false)) if err != nil { t.Fatal(err) } @@ -243,7 +249,7 @@ func TestFormatJSON_userQueriesRenderStructuredEdgeValues(t *testing.T) { {Name: "a.b", Value: map[string]any{"c": "d"}, UserQuery: "a.b"}, } - got, err := FormatJSON(facts) + got, err := FormatJSON(NewProjection(facts, false)) if err != nil { t.Fatal(err) } @@ -260,7 +266,7 @@ func TestFormatYAML_noUserQueryBuildsStructuredFacts(t *testing.T) { {Name: "os.architecture", Value: "x86_64"}, } - got := FormatYAML(facts) + got := FormatYAML(NewProjection(facts, false)) want := "os:\n architecture: x86_64\n family: \"Darwin\"\n name: \"Darwin\"\n" if got != want { t.Fatalf("FormatYAML() = %q, want %q", got, want) @@ -272,7 +278,7 @@ func TestFormatYAML_singleUserQueryUsesOriginalQueryKey(t *testing.T) { {Name: "os.name", Value: "Darwin", UserQuery: "os.name"}, } - got := FormatYAML(facts) + got := FormatYAML(NewProjection(facts, false)) want := "os.name: \"Darwin\"\n" if got != want { t.Fatalf("FormatYAML() = %q, want %q", got, want) @@ -285,7 +291,7 @@ func TestFormatYAML_multipleUserQueriesUseOriginalQueryKeys(t *testing.T) { {Name: "os.family", Value: "Darwin", UserQuery: "os.family"}, } - got := FormatYAML(facts) + got := FormatYAML(NewProjection(facts, false)) want := "os.family: \"Darwin\"\nos.name: \"Darwin\"\n" if got != want { t.Fatalf("FormatYAML() = %q, want %q", got, want) @@ -317,7 +323,7 @@ func TestFormatYAML_quotesUnsafeKeys(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := FormatYAML([]ResolvedFact{tt.fact}) + got := FormatYAML(NewProjection([]ResolvedFact{tt.fact}, false)) if got != tt.want { t.Fatalf("FormatYAML() = %q, want %q", got, tt.want) } @@ -330,7 +336,7 @@ func TestFormatYAML_formatsArrayValuesAsSequences(t *testing.T) { {Name: "arr_ext_fact", Value: []any{"ex1", "ex2"}, UserQuery: "arr_ext_fact"}, } - got := FormatYAML(facts) + got := FormatYAML(NewProjection(facts, false)) want := "arr_ext_fact:\n- ex1\n- ex2\n" if got != want { t.Fatalf("FormatYAML() = %q, want %q", got, want) @@ -342,7 +348,7 @@ func TestFormatYAML_quotesWindowsPath(t *testing.T) { {Name: "path", Value: `C:\Program Files\Puppet Labs\Puppet\bin;C:\cygwin64\bin`}, } - got := FormatYAML(facts) + got := FormatYAML(NewProjection(facts, false)) want := "path: \"C:\\\\Program Files\\\\Puppet Labs\\\\Puppet\\\\bin;C:\\\\cygwin64\\\\bin\"\n" if got != want { t.Fatalf("FormatYAML() = %q, want %q", got, want) @@ -354,7 +360,7 @@ func TestFormatYAML_formatsFloatWithoutQuotes(t *testing.T) { {Name: "load_average", Value: 1.35}, } - got := FormatYAML(facts) + got := FormatYAML(NewProjection(facts, false)) want := "load_average: 1.35\n" if got != want { t.Fatalf("FormatYAML() = %q, want %q", got, want) @@ -366,7 +372,7 @@ func TestFormatYAML_formatsNestedArrayValuesAsYAML(t *testing.T) { {Name: "nested", Value: []any{[]any{"a", "b"}, map[string]any{"name": "c"}}, UserQuery: "nested"}, } - got := FormatYAML(facts) + got := FormatYAML(NewProjection(facts, false)) want := "nested:\n- [a, b]\n- name: c\n" if got != want { t.Fatalf("FormatYAML() = %q, want %q", got, want) @@ -378,7 +384,7 @@ func TestFormatYAML_formatsMultiKeyMapsInSequencesAsValidYAML(t *testing.T) { {Name: "nested", Value: []any{map[string]any{"b": 2, "a": "true"}}, UserQuery: "nested"}, } - got := FormatYAML(facts) + got := FormatYAML(NewProjection(facts, false)) want := "nested:\n- {a: 'true', b: 2}\n" if got != want { t.Fatalf("FormatYAML() = %q, want %q", got, want) @@ -395,7 +401,7 @@ func TestFormatYAML_quotesStringValuesThatYAMLWouldParseAsScalars(t *testing.T) {Name: "feature.upper_empty", Value: "NULL"}, } - got := FormatYAML(facts) + got := FormatYAML(NewProjection(facts, false)) want := "feature:\n disabled: 'false'\n empty: \"null\"\n enabled: 'true'\n upper_disabled: \"FALSE\"\n upper_empty: \"NULL\"\n upper_enabled: \"TRUE\"\n" if got != want { t.Fatalf("FormatYAML() = %q, want %q", got, want) @@ -412,7 +418,7 @@ func TestFormatYAML_userQueriesRenderMapsNilWindowsPathIPv6AndScalarQuoting(t *t {Name: "under", Value: "x_y", UserQuery: "under"}, } - got := FormatYAML(facts) + got := FormatYAML(NewProjection(facts, false)) want := "empty: \"null\"\nenabled: 'true'\nnetworking:\n ip6: \"::1\"\n maybe: \"\"\npath: \"C:\\\\Program Files\\\\Puppet Labs\\\\Puppet\\\\bin\"\nroles:\n- web\n- db\nunder: \"x_y\"\n" if got != want { t.Fatalf("FormatYAML() = %q, want %q", got, want) @@ -426,7 +432,7 @@ func TestFormatHOCON_noUserQueryBuildsStructuredFacts(t *testing.T) { {Name: "os.architecture", Value: "x86_64"}, } - got := FormatHOCON(facts) + got := FormatHOCON(NewProjection(facts, false)) want := "os={\n architecture=\"x86_64\"\n family=Darwin\n name=Darwin\n}\n" if got != want { t.Fatalf("FormatHOCON() = %q, want %q", got, want) @@ -438,7 +444,7 @@ func TestFormatHOCON_singleUserQueryReturnsScalar(t *testing.T) { {Name: "os.name", Value: "Darwin", UserQuery: "os.name"}, } - if got, want := FormatHOCON(facts), "Darwin"; got != want { + if got, want := FormatHOCON(NewProjection(facts, false)), "Darwin"; got != want { t.Fatalf("FormatHOCON() = %q, want %q", got, want) } } @@ -449,7 +455,7 @@ func TestFormatHOCON_multipleUserQueriesUseQuotedQueryKeys(t *testing.T) { {Name: "os.family", Value: "Darwin", UserQuery: "os.family"}, } - got := FormatHOCON(facts) + got := FormatHOCON(NewProjection(facts, false)) want := "\"os.family\"=Darwin\n\"os.name\"=Darwin\n" if got != want { t.Fatalf("FormatHOCON() = %q, want %q", got, want) @@ -461,7 +467,7 @@ func TestFormatHOCON_quotesUnsafeKeys(t *testing.T) { {Name: "bad\x07key", Value: "value", Type: "external"}, } - got := FormatHOCON(facts) + got := FormatHOCON(NewProjection(facts, false)) want := "\"bad\\u0007key\"=value\n" if got != want { t.Fatalf("FormatHOCON() = %q, want %q", got, want) @@ -469,7 +475,7 @@ func TestFormatHOCON_quotesUnsafeKeys(t *testing.T) { } func TestFormatHOCON_emptyFactsReturnsEmptyOutput(t *testing.T) { - if got := FormatHOCON(nil); got != "" { + if got := FormatHOCON(NewProjection(nil, false)); got != "" { t.Fatalf("FormatHOCON() = %q, want empty", got) } } @@ -479,7 +485,7 @@ func TestFormatHOCON_formatsArrayValues(t *testing.T) { {Name: "processors.models", Value: []any{"Apple M4 Pro", "Apple M4 Max"}}, } - got := FormatHOCON(facts) + got := FormatHOCON(NewProjection(facts, false)) want := "processors={\n models=[\"Apple M4 Pro\",\"Apple M4 Max\"]\n}\n" if got != want { t.Fatalf("FormatHOCON() = %q, want %q", got, want) @@ -491,7 +497,7 @@ func TestFormatHOCON_quotesUnsafeStringValues(t *testing.T) { {Name: "external.payload", Value: "a=b # not syntax"}, } - got := FormatHOCON(facts) + got := FormatHOCON(NewProjection(facts, false)) want := "external={\n payload=\"a=b # not syntax\"\n}\n" if got != want { t.Fatalf("FormatHOCON() = %q, want %q", got, want) @@ -503,7 +509,7 @@ func TestFormatHOCON_preservesFloatPrecision(t *testing.T) { {Name: "load_average", Value: 1.35}, } - got := FormatHOCON(facts) + got := FormatHOCON(NewProjection(facts, false)) want := "load_average=1.35\n" if got != want { t.Fatalf("FormatHOCON() = %q, want %q", got, want) @@ -515,7 +521,7 @@ func TestFormatHOCON_singleNilQueryReturnsEmptyScalar(t *testing.T) { {Name: "my_external_fact", UserQuery: "my_external_fact", Value: nil}, } - if got := FormatHOCON(facts); got != "" { + if got := FormatHOCON(NewProjection(facts, false)); got != "" { t.Fatalf("FormatHOCON() = %q, want empty", got) } } @@ -534,7 +540,7 @@ func TestFormatHOCON_singleQueriesRenderTypedSlicesAndGenericMaps(t *testing.T) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { facts := []ResolvedFact{{Name: "query", UserQuery: "query", Value: tt.value}} - if got := FormatHOCON(facts); got != tt.want { + if got := FormatHOCON(NewProjection(facts, false)); got != tt.want { t.Fatalf("FormatHOCON() = %q, want %q", got, tt.want) } }) @@ -551,7 +557,7 @@ func TestFormatLegacy_singleUserQueryDigsIntoArraysAndMaps(t *testing.T) { }, } - if got, want := FormatLegacy([]ResolvedFact{fact}), "second"; got != want { + if got, want := FormatLegacy(NewProjection([]ResolvedFact{fact}, false)), "second"; got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -563,7 +569,7 @@ func TestFormatLegacy_wrongNestedQueryWithStringLeafReturnsEmpty(t *testing.T) { Value: map[string]any{"/tmp": "something"}, } - if got := FormatLegacy([]ResolvedFact{fact}); got != "" { + if got := FormatLegacy(NewProjection([]ResolvedFact{fact}, false)); got != "" { t.Fatalf("FormatLegacy() = %q, want empty", got) } } @@ -575,7 +581,7 @@ func TestFormatLegacy_multipleQueriesPrintEmptyValues(t *testing.T) { } want := "nil_resolved_fact1 => \nresolved_fact2 => resolved_fact2_value" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -587,7 +593,7 @@ func TestFormatLegacy_multipleQueriesPrintNestedNilValuesWithUserQueryKey(t *tes } want := "my.nested.fact2 => \nnil_resolved_fact1 => " - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -599,7 +605,7 @@ func TestFormatLegacy_noUserQueryOmitsNilFacts(t *testing.T) { } want := "resolved_fact2 => resolved_fact2_value" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -610,7 +616,7 @@ func TestFormatLegacy_noUserQueryPreservesNewlinesInValues(t *testing.T) { } want := "custom_fact => value1 \n value2" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -621,7 +627,7 @@ func TestFormatLegacy_quotesIPv6StringsInsideMaps(t *testing.T) { } want := "{\n ip6 => \"::1\"\n}" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -632,7 +638,7 @@ func TestFormatLegacy_noUserQueryPreservesWindowsPathFactNames(t *testing.T) { } want := `C:\Program Files\App => bin_dir` - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -643,7 +649,7 @@ func TestFormatLegacy_noUserQueryPreservesWindowsPathValues(t *testing.T) { } want := `path => C:\Program Files\Puppet Labs\Puppet\bin;C:\cygwin64\bin` - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -666,7 +672,7 @@ func TestFormatLegacy_noUserQueryQuotesNestedStringsAndSeparatesEntriesWithComma } want := "identity => {\n gid => 20,\n group => \"staff\",\n privileged => false,\n uid => 501,\n user => \"ncode\"\n}\nkernel => Darwin" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -680,7 +686,7 @@ func TestFormatLegacy_noUserQueryRendersArraysMultiLine(t *testing.T) { } want := "processors => {\n count => 2,\n models => [\n \"Apple M4\",\n \"Apple M4\"\n ]\n}" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -691,7 +697,7 @@ func TestFormatLegacy_noUserQueryRendersTopLevelArray(t *testing.T) { } want := "roles => [\n \"web\",\n \"db\"\n]" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -703,7 +709,7 @@ func TestFormatLegacy_noUserQueryStripsCommaBetweenAdjacentTopLevelMaps(t *testi } want := "a => {\n x => 1\n}\nb => {\n y => 2\n}" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -714,7 +720,7 @@ func TestFormatLegacy_noUserQueryRendersFloatsLikeRuby(t *testing.T) { } want := "load_averages => {\n 15m => 1.35,\n 1m => 1.46,\n 5m => 1.4\n}" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -724,7 +730,7 @@ func TestFormatLegacy_noUserQueryRendersEmptyMap(t *testing.T) { {Name: "mountpoints", Value: map[string]any{}}, } - if got, want := FormatLegacy(facts), "mountpoints => {}"; got != want { + if got, want := FormatLegacy(NewProjection(facts, false)), "mountpoints => {}"; got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -734,7 +740,7 @@ func TestFormatLegacy_noUserQueryUnescapesQuotesInTopLevelStringValues(t *testin {Name: "motd", Value: `say "hi" now`}, } - if got, want := FormatLegacy(facts), `motd => say "hi" now`; got != want { + if got, want := FormatLegacy(NewProjection(facts, false)), `motd => say "hi" now`; got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -748,7 +754,7 @@ func TestFormatLegacy_valueContainingKeyDelimiterManglesExactlyLikeRuby(t *testi {Name: "weird", Value: `x": y`}, } - if got, want := FormatLegacy(facts), `weird => x\ => y`; got != want { + if got, want := FormatLegacy(NewProjection(facts, false)), `weird => x\ => y`; got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -762,7 +768,7 @@ func TestFormatLegacy_multipleQueriesUnquoteTopLevelStringsAndRenderNilEmpty(t * } want := "identity.user => ncode\nkernel => Darwin\nload => 1.35\nmissing => " - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -774,7 +780,7 @@ func TestFormatLegacy_multipleQueriesKeepNestedQuotingAndCommas(t *testing.T) { } want := "identity => {\n gid => 20,\n user => \"ncode\"\n}\nkernel => Darwin" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -789,7 +795,7 @@ func TestFormatLegacy_singleQueryKeepsBracesCommasAndNestedQuotes(t *testing.T) } want := "{\n gid => 20,\n group => \"staff\",\n user => \"ncode\"\n}" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -800,7 +806,7 @@ func TestFormatLegacy_singleQueryRendersArrayMultiLineWithQuotedElements(t *test } want := "[\n \"Apple M4\",\n \"Apple M4\"\n]" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -813,7 +819,7 @@ func TestFormatLegacy_singleQueryDoesNotExpandEmbeddedNewlines(t *testing.T) { } want := "{\n key => \"a\\nb\"\n}" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -824,7 +830,7 @@ func TestFormatLegacy_noUserQueryExpandsEmbeddedNewlines(t *testing.T) { } want := "sshfp => SSHFP 1 1 abc\nSSHFP 2 2 def" - if got := FormatLegacy(facts); got != want { + if got := FormatLegacy(NewProjection(facts, false)); got != want { t.Fatalf("FormatLegacy() = %q, want %q", got, want) } } @@ -843,7 +849,7 @@ func TestFormatLegacy_singleQueryNonStringScalars(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { facts := []ResolvedFact{{Name: "q", UserQuery: "q", Value: tt.value}} - if got := FormatLegacy(facts); got != tt.want { + if got := FormatLegacy(NewProjection(facts, false)); got != tt.want { t.Fatalf("FormatLegacy() = %q, want %q", got, tt.want) } }) @@ -862,7 +868,7 @@ func TestFormatLegacyColored_colorsKeysByNestingDepth(t *testing.T) { " }\n" + "}\n" + "\x1b[36mkernel\x1b[0m => Darwin" - if got := FormatLegacyColored(facts, false, true); got != want { + if got := FormatLegacyColored(NewProjection(facts, false), true); got != want { t.Fatalf("FormatLegacyColored() = %q, want %q", got, want) } } @@ -872,7 +878,7 @@ func TestFormatLegacyColored_paletteCyclesPastDepthFive(t *testing.T) { tree := map[string]any{"a": map[string]any{"b": map[string]any{"c": map[string]any{"d": map[string]any{"e": depth5}}}}} facts := []ResolvedFact{{Name: "root", Value: tree}} - got := FormatLegacyColored(facts, false, true) + got := FormatLegacyColored(NewProjection(facts, false), true) for _, want := range []string{ "\x1b[36mroot\x1b[0m => {", // depth 0: cyan " \x1b[33ma\x1b[0m => {", // depth 1: yellow @@ -894,8 +900,8 @@ func TestFormatLegacyColored_offLeavesOutputByteIdentical(t *testing.T) { {Name: "kernel", Value: "Darwin"}, } - plain := FormatLegacy(facts) - if got := FormatLegacyColored(facts, false, false); got != plain { + plain := FormatLegacy(NewProjection(facts, false)) + if got := FormatLegacyColored(NewProjection(facts, false), false); got != plain { t.Fatalf("FormatLegacyColored(colorize=false) = %q, want %q", got, plain) } if strings.Contains(plain, "\x1b[") { @@ -909,7 +915,7 @@ func TestFormatLegacyColored_singleQueryColorsKeysOnly(t *testing.T) { } want := "{\n \x1b[33mgid\x1b[0m => 20,\n \x1b[33muser\x1b[0m => \"ncode\"\n}" - if got := FormatLegacyColored(facts, false, true); got != want { + if got := FormatLegacyColored(NewProjection(facts, false), true); got != want { t.Fatalf("FormatLegacyColored() = %q, want %q", got, want) } } @@ -921,7 +927,7 @@ func TestFormatLegacyColored_multipleQueriesColorTopLevelKeys(t *testing.T) { } want := "\x1b[36midentity\x1b[0m => {\n \x1b[33muser\x1b[0m => \"ncode\"\n}\n\x1b[36mkernel\x1b[0m => Darwin" - if got := FormatLegacyColored(facts, false, true); got != want { + if got := FormatLegacyColored(NewProjection(facts, false), true); got != want { t.Fatalf("FormatLegacyColored() = %q, want %q", got, want) } } @@ -968,14 +974,14 @@ func TestFormatterScalarRendering(t *testing.T) { t.Run("yaml/"+tt.name, func(t *testing.T) { facts := []ResolvedFact{{Name: "value", UserQuery: "value", Value: tt.value}} want := "value: " + tt.want + "\n" - if got := FormatYAML(facts); got != want { + if got := FormatYAML(NewProjection(facts, false)); got != want { t.Fatalf("FormatYAML(%#v) = %q, want %q", tt.value, got, want) } }) } facts := []ResolvedFact{{Name: "values", UserQuery: "values", Value: []any{"hello", 7, true}}} - if got, want := FormatHOCON(facts), `["hello",7,true]`; got != want { + if got, want := FormatHOCON(NewProjection(facts, false)), `["hello",7,true]`; got != want { t.Fatalf("FormatHOCON() = %q, want %q", got, want) } } diff --git a/internal/engine/memory_test.go b/internal/engine/memory_test.go index c3663eac..911763b1 100644 --- a/internal/engine/memory_test.go +++ b/internal/engine/memory_test.go @@ -350,29 +350,6 @@ func TestDarwinMemoryParsersHandleMalformedAndNonDarwinInputs(t *testing.T) { } } -func TestBytesToMB(t *testing.T) { - tests := []struct { - name string - value any - want any - }{ - {"bytes", 256_586_343, 244.6998052597046}, - {"string bytes", "2343455", 2.2348928451538086}, - {"string bytes with suffix", "2343455abc", 2.2348928451538086}, - {"non numeric string", "not-a-number", 0.0}, - {"zero", 0, 0.0}, - {"nil", nil, nil}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := bytesToMB(tt.value); got != tt.want { - t.Fatalf("bytesToMB(%#v) = %#v, want %#v", tt.value, got, tt.want) - } - }) - } -} - func TestBytesToHumanReadable(t *testing.T) { tests := []struct { name string diff --git a/internal/engine/networking.go b/internal/engine/networking.go index bd907166..8d00ef44 100644 --- a/internal/engine/networking.go +++ b/internal/engine/networking.go @@ -1172,11 +1172,6 @@ func linuxDHCPServerFromLeaseDir(dir, interfaceName string, host hostOS) string return "" } -func linuxDHClientDHCPServerForInterface(content, interfaceName string) (string, bool) { - server, _, explicit := linuxDHClientDHCPServerForInterfaceState(content, interfaceName) - return server, explicit -} - func linuxDHClientDHCPServerForInterfaceState(content, interfaceName string) (string, bool, bool) { if !dhclientContentHasInterface(content) { return "", false, false diff --git a/internal/engine/networking_test.go b/internal/engine/networking_test.go index 6f58d6fe..235bd510 100644 --- a/internal/engine/networking_test.go +++ b/internal/engine/networking_test.go @@ -1860,7 +1860,34 @@ func TestLinuxDHCPServerFromLeaseDirStopsAtMatchingLeaseWithoutServer(t *testing } } -func TestLinuxDHClientDHCPServerForInterfaceFallsBackWhenInterfaceIsOutsideLeaseBlock(t *testing.T) { +func TestLinuxDHClientDHCPServerForInterfaceStateTracksMatchAndExplicit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + server string + matched bool + explicit bool + }{ + {name: "no interface", content: `lease { option dhcp-server-identifier 10.32.10.163; }`}, + {name: "other interface", content: `lease { interface "eth1"; option dhcp-server-identifier 10.99.99.99; }`, explicit: true}, + {name: "matched without server", content: `lease { interface "eth0"; option host-name "edge"; }`, matched: true, explicit: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server, matched, explicit := linuxDHClientDHCPServerForInterfaceState(tt.content, "eth0") + if server != tt.server || matched != tt.matched || explicit != tt.explicit { + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() = (%q, %v, %v), want (%q, %v, %v)", server, matched, explicit, tt.server, tt.matched, tt.explicit) + } + }) + } +} + +func TestLinuxDHClientDHCPServerForInterfaceStateFallsBackWhenInterfaceIsOutsideLeaseBlock(t *testing.T) { t.Parallel() content := `interface "eth0"; @@ -1868,12 +1895,12 @@ lease { option dhcp-server-identifier 10.32.10.163; }` - got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") - if !ok { - t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + got, matched, explicit := linuxDHClientDHCPServerForInterfaceState(content, "eth0") + if !matched || !explicit { + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() state = (%v, %v), want (true, true)", matched, explicit) } if got != "10.32.10.163" { - t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want 10.32.10.163", got) + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() = %q, want 10.32.10.163", got) } content = `interface "eth0"; @@ -1884,12 +1911,12 @@ lease { interface "eth1"; option dhcp-server-identifier 10.99.99.99; }` - got, ok = linuxDHClientDHCPServerForInterface(content, "eth0") - if !ok { - t.Fatal("linuxDHClientDHCPServerForInterface(mixed) ok = false, want true") + got, matched, explicit = linuxDHClientDHCPServerForInterfaceState(content, "eth0") + if !matched || !explicit { + t.Fatalf("linuxDHClientDHCPServerForInterfaceState(mixed) state = (%v, %v), want (true, true)", matched, explicit) } if got != "" { - t.Fatalf("linuxDHClientDHCPServerForInterface(mixed) = %q, want empty ambiguous mixed lease", got) + t.Fatalf("linuxDHClientDHCPServerForInterfaceState(mixed) = %q, want empty ambiguous mixed lease", got) } content = `interface "eth0"; @@ -1899,16 +1926,16 @@ lease { lease { option dhcp-server-identifier 10.99.99.99; }` - got, ok = linuxDHClientDHCPServerForInterface(content, "eth0") - if !ok { - t.Fatal("linuxDHClientDHCPServerForInterface(multiple unqualified) ok = false, want true") + got, matched, explicit = linuxDHClientDHCPServerForInterfaceState(content, "eth0") + if !matched || !explicit { + t.Fatalf("linuxDHClientDHCPServerForInterfaceState(multiple unqualified) state = (%v, %v), want (true, true)", matched, explicit) } if got != "10.99.99.99" { - t.Fatalf("linuxDHClientDHCPServerForInterface(multiple unqualified) = %q, want latest matching lease", got) + t.Fatalf("linuxDHClientDHCPServerForInterfaceState(multiple unqualified) = %q, want latest matching lease", got) } } -func TestLinuxDHClientDHCPServerForInterfaceIgnoresCommentAndQuotedBraces(t *testing.T) { +func TestLinuxDHClientDHCPServerForInterfaceStateIgnoresCommentAndQuotedBraces(t *testing.T) { t.Parallel() content := `# lease { ignored } @@ -1923,16 +1950,16 @@ lease { option dhcp-server-identifier 10.99.99.99; }` - got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") - if !ok { - t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + got, matched, explicit := linuxDHClientDHCPServerForInterfaceState(content, "eth0") + if !matched || !explicit { + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() state = (%v, %v), want (true, true)", matched, explicit) } if got != "10.32.10.163" { - t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want 10.32.10.163", got) + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() = %q, want 10.32.10.163", got) } } -func TestLinuxDHClientDHCPServerForInterfaceSkipsHeaderAndInterfaceComments(t *testing.T) { +func TestLinuxDHClientDHCPServerForInterfaceStateSkipsHeaderAndInterfaceComments(t *testing.T) { t.Parallel() content := `lease # dhclient permits comments in whitespace @@ -1947,34 +1974,34 @@ lease # another header comment option dhcp-server-identifier 10.99.99.99; }` - got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") - if !ok { - t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + got, matched, explicit := linuxDHClientDHCPServerForInterfaceState(content, "eth0") + if !matched || !explicit { + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() state = (%v, %v), want (true, true)", matched, explicit) } if got != "10.32.10.163" { - t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want 10.32.10.163", got) + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() = %q, want 10.32.10.163", got) } if blocks := linuxDHClientLeaseBlocks(content); len(blocks) != 2 { t.Fatalf("linuxDHClientLeaseBlocks() found %d blocks, want 2", len(blocks)) } } -func TestLinuxDHClientDHCPServerForInterfaceMatchesInlineInterfaceStatement(t *testing.T) { +func TestLinuxDHClientDHCPServerForInterfaceStateMatchesInlineInterfaceStatement(t *testing.T) { t.Parallel() content := `lease { interface "eth1"; option dhcp-server-identifier 10.99.99.99; } lease { interface "eth0"; option dhcp-server-identifier 10.32.10.163; }` - got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") - if !ok { - t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + got, matched, explicit := linuxDHClientDHCPServerForInterfaceState(content, "eth0") + if !matched || !explicit { + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() state = (%v, %v), want (true, true)", matched, explicit) } if got != "10.32.10.163" { - t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want 10.32.10.163", got) + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() = %q, want 10.32.10.163", got) } } -func TestLinuxDHClientDHCPServerForInterfaceLatestMatchingLeaseWithoutServerWins(t *testing.T) { +func TestLinuxDHClientDHCPServerForInterfaceStateLatestMatchingLeaseWithoutServerWins(t *testing.T) { t.Parallel() content := `lease { @@ -1987,16 +2014,16 @@ lease { # option dhcp-server-identifier 10.99.99.99; }` - got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") - if !ok { - t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + got, matched, explicit := linuxDHClientDHCPServerForInterfaceState(content, "eth0") + if !matched || !explicit { + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() state = (%v, %v), want (true, true)", matched, explicit) } if got != "" { - t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want empty latest matching lease server", got) + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() = %q, want empty latest matching lease server", got) } } -func TestLinuxDHClientDHCPServerForInterfaceResyncsAfterMalformedLeaseBlock(t *testing.T) { +func TestLinuxDHClientDHCPServerForInterfaceStateResyncsAfterMalformedLeaseBlock(t *testing.T) { t.Parallel() content := `lease { @@ -2007,12 +2034,12 @@ lease { option dhcp-server-identifier 10.99.99.99; }` - got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") - if !ok { - t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + got, matched, explicit := linuxDHClientDHCPServerForInterfaceState(content, "eth0") + if !matched || !explicit { + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() state = (%v, %v), want (true, true)", matched, explicit) } if got != "" { - t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want empty when only later valid block belongs to eth1", got) + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() = %q, want empty when only later valid block belongs to eth1", got) } blocks := linuxDHClientLeaseBlocks(content) @@ -2040,7 +2067,7 @@ func TestLinuxDHClientLeaseBlocksResyncsAcrossRepeatedMalformedBlocks(t *testing } } -func TestLinuxDHClientDHCPServerForInterfaceResyncsAfterUnterminatedQuotedString(t *testing.T) { +func TestLinuxDHClientDHCPServerForInterfaceStateResyncsAfterUnterminatedQuotedString(t *testing.T) { t.Parallel() content := `lease { @@ -2051,12 +2078,12 @@ lease { option dhcp-server-identifier 10.99.99.99; }` - got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") - if !ok { - t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + got, matched, explicit := linuxDHClientDHCPServerForInterfaceState(content, "eth0") + if !matched || !explicit { + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() state = (%v, %v), want (true, true)", matched, explicit) } if got != "" { - t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want empty when only later valid block belongs to eth1", got) + t.Fatalf("linuxDHClientDHCPServerForInterfaceState() = %q, want empty when only later valid block belongs to eth1", got) } blocks := linuxDHClientLeaseBlocks(content) diff --git a/internal/engine/projection.go b/internal/engine/projection.go index 8c35376a..00ef1701 100644 --- a/internal/engine/projection.go +++ b/internal/engine/projection.go @@ -14,6 +14,10 @@ import ( // // A Projection memoizes the canonical tree it builds for fallback digs, so // repeated lookups over one Projection do not rebuild the tree per call. +// Discovery Projections select queries with the discovery plan's dotted mode; +// a Snapshot retains a canonical non-force-dot Projection; OutputProjection +// creates a defensive CLI presentation Projection; and the version fast path +// builds its own synthetic one-fact Projection. type Projection struct { facts []ResolvedFact includeTypedDotted bool @@ -50,10 +54,10 @@ func (p *Projection) Collection() map[string]any { return p.tree } -// Select returns one resolved fact per query, applying reverse-precedence +// selectFacts returns one resolved fact per query, applying reverse-precedence // selection, wildcard matching, and canonical tree fallback. With no queries it // returns the backing facts unchanged, matching the full-output contract. -func (p *Projection) Select(queries []string) []ResolvedFact { +func (p *Projection) selectFacts(queries []string) []ResolvedFact { if len(queries) == 0 { return p.facts } @@ -71,7 +75,7 @@ func (p *Projection) Select(queries []string) []ResolvedFact { // fact resolved is missing (value nil, found false). This is the Snapshot // missing-vs-nil contract. func (p *Projection) LookupValue(query string) (value any, found bool) { - fact := p.Select([]string{query})[0] + fact := p.selectFacts([]string{query})[0] if v, found := valueForQuery(fact); found { return v, true } @@ -84,9 +88,9 @@ func (p *Projection) LookupValue(query string) (value any, found bool) { // MissingQueries returns the user queries among selected facts that no fact // resolved to a non-nil value, in order, for CLI strict-mode reporting. A // selected fact with an empty UserQuery (full output) is never missing. -func (p *Projection) MissingQueries(selected []ResolvedFact) []string { +func (p *Projection) MissingQueries() []string { missing := make([]string, 0) - for _, fact := range selected { + for _, fact := range p.facts { if fact.UserQuery != "" && ValueForQuery(fact) == nil { missing = append(missing, fact.UserQuery) } @@ -94,6 +98,20 @@ func (p *Projection) MissingQueries(selected []ResolvedFact) []string { return missing } +// PresentationNames returns one display name per backing fact in order. Query +// output uses the original user query; full output falls back to the fact name. +func (p *Projection) PresentationNames() []string { + names := make([]string, 0, len(p.facts)) + for _, fact := range p.facts { + name := fact.UserQuery + if name == "" { + name = fact.Name + } + names = append(names, name) + } + return names +} + // OutputShape names the projection shape a formatter renders. type OutputShape int @@ -143,6 +161,27 @@ func (p *Projection) MultiQueryValues() map[string]any { return factsForQueries(p.facts) } +func uniqueQueries(facts []ResolvedFact) []string { + seen := make(map[string]bool, len(facts)) + queries := make([]string, 0, len(facts)) + for _, fact := range facts { + if seen[fact.UserQuery] { + continue + } + seen[fact.UserQuery] = true + queries = append(queries, fact.UserQuery) + } + return queries +} + +func factsForQueries(facts []ResolvedFact) map[string]any { + values := make(map[string]any, len(facts)) + for _, fact := range facts { + values[fact.UserQuery] = ValueForQuery(fact) + } + return values +} + // findFactIn returns the resolved fact for query using a precomputed // collection for the nested-query fallback dig, so callers that already hold // the canonical tree do not rebuild it. diff --git a/internal/engine/projection_test.go b/internal/engine/projection_test.go index 0a117916..b95c227a 100644 --- a/internal/engine/projection_test.go +++ b/internal/engine/projection_test.go @@ -14,7 +14,7 @@ func TestProjectionSelectExtractsSelectedQueryValues(t *testing.T) { } projection := NewProjection(facts, false) - selected := projection.Select([]string{"os.release.major", "kernel"}) + selected := projection.selectFacts([]string{"os.release.major", "kernel"}) if len(selected) != 2 { t.Fatalf("Select() returned %d facts, want 2", len(selected)) } @@ -52,7 +52,7 @@ func TestProjectionDottedFactModeMergesPartialQuery(t *testing.T) { {Name: "a.b.c", Value: "external", Type: "external"}, } - dotted := NewProjection(facts, true).Select([]string{"a.b", "a"}) + dotted := NewProjection(facts, true).selectFacts([]string{"a.b", "a"}) if got, want := ValueForQuery(dotted[0]), (map[string]any{"c": "external"}); !reflect.DeepEqual(got, want) { t.Fatalf("dotted ValueForQuery(a.b) = %#v, want %#v", got, want) } @@ -60,7 +60,7 @@ func TestProjectionDottedFactModeMergesPartialQuery(t *testing.T) { t.Fatalf("dotted ValueForQuery(a) = %#v, want %#v", got, want) } - flat := NewProjection(facts, false).Select([]string{"a.b", "a"}) + flat := NewProjection(facts, false).selectFacts([]string{"a.b", "a"}) if got := ValueForQuery(flat[0]); got != nil { t.Fatalf("flat ValueForQuery(a.b) = %#v, want nil", got) } @@ -90,6 +90,14 @@ func TestProjectionShapeClassification(t *testing.T) { facts: []ResolvedFact{{Name: "kernel", Value: "Darwin", UserQuery: "kernel"}}, want: ShapeSingleQuery, }, + { + name: "repeated user query is single", + facts: []ResolvedFact{ + {Name: "kernel", Value: "Darwin", UserQuery: "kernel"}, + {Name: "kernel", Value: "Darwin", UserQuery: "kernel"}, + }, + want: ShapeSingleQuery, + }, { name: "multiple user queries is multi", facts: []ResolvedFact{ @@ -116,9 +124,11 @@ func TestProjectionMissingQueriesReportsUnresolvedQueries(t *testing.T) { facts := []ResolvedFact{ {Name: "kernel", Value: "Darwin", UserQuery: "kernel"}, {Name: "nope", UserQuery: "nope", Type: "nil"}, + {Name: "still_present", Value: true, UserQuery: "still_present"}, + {Name: "nope", UserQuery: "nope", Type: "nil"}, } - missing := NewProjection(facts, false).MissingQueries(facts) - if want := []string{"nope"}; !reflect.DeepEqual(missing, want) { + missing := NewProjection(facts, false).MissingQueries() + if want := []string{"nope", "nope"}; !reflect.DeepEqual(missing, want) { t.Fatalf("MissingQueries() = %#v, want %#v", missing, want) } } @@ -130,7 +140,7 @@ func TestProjectionMissingQueriesTreatsResolvedNilSelectedValueAsMissing(t *test {Name: "blank", Value: nil, UserQuery: "blank", Type: "external"}, {Name: "blank_custom", Value: nil, UserQuery: "blank_custom", Type: "custom"}, } - missing := NewProjection(facts, false).MissingQueries(facts) + missing := NewProjection(facts, false).MissingQueries() if want := []string{"blank", "blank_custom"}; !reflect.DeepEqual(missing, want) { t.Fatalf("MissingQueries() = %#v, want %#v", missing, want) } @@ -142,12 +152,26 @@ func TestProjectionMissingQueriesIgnoresFullOutputFacts(t *testing.T) { {Name: "kernel", Value: "Darwin"}, {Name: "blank", Value: nil, Type: "external"}, } - missing := NewProjection(facts, false).MissingQueries(facts) + missing := NewProjection(facts, false).MissingQueries() if len(missing) != 0 { t.Fatalf("MissingQueries() = %#v, want empty", missing) } } +func TestProjectionPresentationNamesPreservesOrderAndDuplicates(t *testing.T) { + facts := []ResolvedFact{ + {Name: "os", UserQuery: "os.name"}, + {Name: "kernel"}, + {Name: "os", UserQuery: "os.name"}, + } + + got := NewProjection(facts, false).PresentationNames() + want := []string{"os.name", "kernel", "os.name"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("PresentationNames() = %#v, want %#v", got, want) + } +} + // Task 1.3: Snapshot lookup keeps missing facts distinct from resolved nil // registered/external facts. @@ -253,14 +277,14 @@ func TestSnapshotReturnedMutableValuesDoNotAffectSnapshot(t *testing.T) { t.Fatalf("Value() after Tree mutation = %#v, want web", value) } - facts := sn.Facts() - facts[0].Value.(map[string]any)["roles"].([]string)[0] = "db" + presentation := sn.OutputProjection(false) + presentation.FullTree()["site"].(map[string]any)["roles"].([]string)[0] = "db" value, err = sn.Value("site.roles.0") if err != nil { t.Fatalf("Value() error = %v", err) } if value != "web" { - t.Fatalf("Value() after Facts mutation = %#v, want web", value) + t.Fatalf("Value() after OutputProjection mutation = %#v, want web", value) } } diff --git a/internal/engine/query_test.go b/internal/engine/query_test.go index ed61592e..f8a6f144 100644 --- a/internal/engine/query_test.go +++ b/internal/engine/query_test.go @@ -10,7 +10,7 @@ func TestProjectionKeepsTypedDottedFactFlatByDefault(t *testing.T) { {Name: "a.b.c", Value: "external", Type: "external"}, } - selected := NewProjection(facts, false).Select([]string{"a.b.c"}) + selected := NewProjection(facts, false).selectFacts([]string{"a.b.c"}) if len(selected) != 1 { t.Fatalf("Select() returned %d facts, want 1", len(selected)) } @@ -24,7 +24,7 @@ func TestProjectionUnmatchedQueryWithRegexMetacharacterReturnsNilFact(t *testing {Name: "a_loaded_fact", Type: "custom"}, } - selected := NewProjection(facts, false).Select([]string{"regex(string"}) + selected := NewProjection(facts, false).selectFacts([]string{"regex(string"}) if len(selected) != 1 { t.Fatalf("Select() returned %d facts, want 1", len(selected)) } @@ -50,7 +50,7 @@ func TestProjectionMatchesWildcardFactNameLikeRubyQueryParser(t *testing.T) { {Name: "os.family", Value: "Debian", Type: "core"}, } - selected := NewProjection(facts, false).Select([]string{"ipaddress_ens160"}) + selected := NewProjection(facts, false).selectFacts([]string{"ipaddress_ens160"}) if len(selected) != 1 { t.Fatalf("Select() returned %d facts, want 1", len(selected)) } @@ -72,7 +72,7 @@ func TestProjectionWildcardFactNameEscapesOtherRegexpCharacters(t *testing.T) { {Name: "metric[prod].*", Value: "literal", Type: "external"}, } - selected := NewProjection(facts, false).Select([]string{"metric[prod]cpu"}) + selected := NewProjection(facts, false).selectFacts([]string{"metric[prod]cpu"}) if len(selected) != 1 { t.Fatalf("Select() returned %d facts, want 1", len(selected)) } @@ -92,7 +92,7 @@ func TestProjectionDoesNotMatchWildcardNameForDottedStructuredQuery(t *testing.T {Name: "ssh", Value: map[string]any{"rsa": map[string]any{"key": "structured"}}, Type: "core"}, } - selected := NewProjection(facts, false).Select([]string{"ssh.rsa.key"}) + selected := NewProjection(facts, false).selectFacts([]string{"ssh.rsa.key"}) if len(selected) != 1 { t.Fatalf("Select() returned %d facts, want 1", len(selected)) } diff --git a/internal/engine/snapshot.go b/internal/engine/snapshot.go index b98723e2..28f291cd 100644 --- a/internal/engine/snapshot.go +++ b/internal/engine/snapshot.go @@ -83,10 +83,11 @@ func (sn *Snapshot) All() iter.Seq2[string, any] { } } -// Facts returns the resolved facts backing the Snapshot, for the CLI's -// formatter pipeline. -func (sn *Snapshot) Facts() []ResolvedFact { - return cloneFacts(sn.facts) +// OutputProjection returns a defensive presentation Projection over the +// Snapshot's selected facts. Its dotted-fact mode is independent from the +// canonical non-force-dot Projection retained for Snapshot queries. +func (sn *Snapshot) OutputProjection(includeTypedDotted bool) *Projection { + return NewProjection(cloneFacts(sn.facts), includeTypedDotted) } func cloneFacts(facts []ResolvedFact) []ResolvedFact { diff --git a/internal/engine/snapshot_test.go b/internal/engine/snapshot_test.go index f1d981da..f7ede405 100644 --- a/internal/engine/snapshot_test.go +++ b/internal/engine/snapshot_test.go @@ -130,21 +130,88 @@ func TestSnapshotTreeReturnsDeepCopy(t *testing.T) { } } -func TestSnapshotFactsReturnsDeepCopies(t *testing.T) { - snap := newSnapshot([]ResolvedFact{ - {Name: "root", Value: map[string]any{"child": []any{"original"}}}, - }, discardLog()) +func TestSnapshotOutputProjectionReturnsDeepCopies(t *testing.T) { + type node struct { + Value string + Next *node + } + type payload struct { + First map[string]any + Second map[string]any + Slice []string + Array [1]*node + Pointer *node + } + + shared := map[string]any{"value": "original"} + cycle := &node{Value: "original"} + cycle.Next = cycle + snap := newSnapshot([]ResolvedFact{{ + Name: "root", + Value: payload{ + First: shared, + Second: shared, + Slice: []string{"original"}, + Array: [1]*node{cycle}, + Pointer: cycle, + }, + }}, discardLog()) - facts := snap.Facts() - facts[0].Value.(map[string]any)["child"].([]any)[0] = "mutated" + projected := snap.OutputProjection(false).FullTree()["root"].(payload) + if reflect.ValueOf(projected.First).Pointer() != reflect.ValueOf(projected.Second).Pointer() { + t.Fatal("OutputProjection() did not preserve a shared map alias") + } + if projected.Array[0] != projected.Pointer { + t.Fatal("OutputProjection() did not preserve a shared pointer alias") + } + if projected.Pointer.Next != projected.Pointer { + t.Fatal("OutputProjection() did not preserve a pointer cycle") + } + projected.First["value"] = "mutated" + projected.Slice[0] = "mutated" + projected.Pointer.Value = "mutated" - got, err := snap.Value("root.child.0") - if err != nil || got != "original" { - t.Fatalf("Value(root.child.0) after Facts mutation = %#v, %v, want original", got, err) + fresh := snap.OutputProjection(false).FullTree()["root"].(payload) + if got := fresh.First["value"]; got != "original" { + t.Fatalf("fresh OutputProjection().First[value] = %#v, want original", got) + } + if got := fresh.Second["value"]; got != "original" { + t.Fatalf("fresh OutputProjection().Second[value] = %#v, want original", got) + } + if got := fresh.Slice[0]; got != "original" { + t.Fatalf("fresh OutputProjection().Slice[0] = %#v, want original", got) + } + if got := fresh.Array[0].Value; got != "original" { + t.Fatalf("fresh OutputProjection().Array[0].Value = %#v, want original", got) } - fresh := snap.Facts() - if got := fresh[0].Value.(map[string]any)["child"].([]any)[0]; got != "original" { - t.Fatalf("fresh Facts()[0] = %#v, want original", got) + if got := fresh.Pointer.Value; got != "original" { + t.Fatalf("fresh OutputProjection().Pointer.Value = %#v, want original", got) + } +} + +func TestSnapshotOutputProjectionKeepsForceDotSeparate(t *testing.T) { + snap := newSnapshot([]ResolvedFact{{Name: "a.b.c", Value: "external", Type: "external"}}, discardLog()) + wantTree := snap.Tree() + + presentation := snap.OutputProjection(true) + got := presentation.FullTree()["a"].(map[string]any)["b"].(map[string]any)["c"] + if got != "external" { + t.Fatalf("force-dot OutputProjection().FullTree()[a][b][c] = %#v, want external", got) + } + presentation.FullTree()["a"].(map[string]any)["b"].(map[string]any)["c"] = "mutated" + + if got := snap.Tree(); !reflect.DeepEqual(got, wantTree) { + t.Fatalf("Tree() after force-dot presentation mutation = %#v, want %#v", got, wantTree) + } + if got, err := snap.Value("a.b"); got != nil || !errors.Is(err, ErrFactNotFound) { + t.Fatalf("Value(a.b) = %#v, %v, want ErrFactNotFound", got, err) + } + var names []string + for name := range snap.All() { + names = append(names, name) + } + if want := []string{"a.b.c"}; !reflect.DeepEqual(names, want) { + t.Fatalf("All() names = %#v, want %#v", names, want) } } @@ -169,11 +236,11 @@ func TestSnapshotCopiesPointerValues(t *testing.T) { t.Fatalf("fresh Value(root) after pointer mutation = %#v, want original", got) } - facts := snap.Facts() - (*facts[0].Value.(*map[string]any))["child"] = "mutated" - fresh := snap.Facts() - if got := (*fresh[0].Value.(*map[string]any))["child"]; got != "original" { - t.Fatalf("fresh Facts()[0] after pointer mutation = %#v, want original", got) + projected := snap.OutputProjection(false).FullTree()["root"].(*map[string]any) + (*projected)["child"] = "mutated" + fresh := snap.OutputProjection(false).FullTree()["root"].(*map[string]any) + if got := (*fresh)["child"]; got != "original" { + t.Fatalf("fresh OutputProjection().FullTree()[root] after pointer mutation = %#v, want original", got) } tree := snap.Tree() diff --git a/internal/engine/ssh.go b/internal/engine/ssh.go index 49a2ca9d..d5b2636d 100644 --- a/internal/engine/ssh.go +++ b/internal/engine/ssh.go @@ -96,10 +96,6 @@ func sshKeyName(keyType string) (string, int, bool) { } } -func sshFacts(keys []sshHostKey) []ResolvedFact { - return sshFactsForPlatform("", keys) -} - func sshFactsForPlatform(goos string, keys []sshHostKey) []ResolvedFact { if len(keys) == 0 { if goos == "openbsd" { diff --git a/internal/engine/ssh_test.go b/internal/engine/ssh_test.go index d21fa456..69e84889 100644 --- a/internal/engine/ssh_test.go +++ b/internal/engine/ssh_test.go @@ -29,7 +29,7 @@ func TestParseSSHHostPublicKeyBuildsStructuredFacts(t *testing.T) { t.Fatalf("entry.SHA256 = %q, want %q", got, want) } - collection := Collection(sshFacts([]sshHostKey{entry})) + collection := Collection(sshFactsForPlatform("linux", []sshHostKey{entry})) ssh, ok := collection["ssh"].(map[string]any) if !ok { t.Fatalf("ssh fact = %#v, want map", collection["ssh"]) @@ -145,7 +145,7 @@ func TestDiscoverSSHHostKeysWindowsReadsProgramDataSSH(t *testing.T) { } func TestSSHFactsPreserveFirstDuplicateKeyType(t *testing.T) { - collection := Collection(sshFacts([]sshHostKey{ + collection := Collection(sshFactsForPlatform("linux", []sshHostKey{ {Name: "rsa", Type: "ssh-rsa", Key: "first", SHA1: "first-sha1", SHA256: "first-sha256"}, {Name: "rsa", Type: "ssh-rsa", Key: "second", SHA1: "second-sha1", SHA256: "second-sha256"}, })) diff --git a/internal/engine/uptime.go b/internal/engine/uptime.go index cf75e459..5309fbcf 100644 --- a/internal/engine/uptime.go +++ b/internal/engine/uptime.go @@ -32,10 +32,6 @@ func probeUptime(s *Session) uptimeInfo { return currentUptimeInfo(s, time.Now) } -func currentUptime(s *Session, now func() time.Time) time.Duration { - return currentUptimeInfo(s, now).Duration -} - func currentUptimeInfo(s *Session, now func() time.Time) uptimeInfo { goos := s.goos() if goos == "windows" { diff --git a/internal/engine/uptime_test.go b/internal/engine/uptime_test.go index 47eed5c9..38b27711 100644 --- a/internal/engine/uptime_test.go +++ b/internal/engine/uptime_test.go @@ -193,9 +193,10 @@ func TestCurrentUptimeFallsBackToKernelBoottime(t *testing.T) { s.host = host now := func() time.Time { return time.Unix(120, 0) } - got := currentUptime(s, now) - if got != time.Minute { - t.Fatalf("currentUptime() = %s, want 1m0s", got) + got := currentUptimeInfo(s, now) + want := uptimeInfo{Duration: time.Minute, Known: true} + if got != want { + t.Fatalf("currentUptimeInfo() = %#v, want %#v", got, want) } if want := []string{"/proc/uptime"}; !reflect.DeepEqual(host.readFileCalls, want) { t.Fatalf("readFile calls = %#v, want %#v", host.readFileCalls, want) @@ -316,7 +317,7 @@ func TestCurrentUptimeInfoBSDsFallBackToUptimeCommand(t *testing.T) { } } -func TestCurrentUptimeUsesWindowsWMITimes(t *testing.T) { +func TestCurrentUptimeInfoUsesWindowsWMITimes(t *testing.T) { t.Parallel() host := &fakeHostOS{ @@ -328,12 +329,13 @@ func TestCurrentUptimeUsesWindowsWMITimes(t *testing.T) { s := NewSession() s.host = host - got := currentUptime(s, time.Now) - if got != 105*time.Minute { - t.Fatalf("currentUptime(windows) = %s, want 1h45m0s", got) + got := currentUptimeInfo(s, time.Now) + want := uptimeInfo{Duration: 105 * time.Minute, Known: true} + if got != want { + t.Fatalf("currentUptimeInfo(windows) = %#v, want %#v", got, want) } if len(host.readFileCalls) != 0 { - t.Fatalf("currentUptime(windows) read %#v, want WMI only", host.readFileCalls) + t.Fatalf("currentUptimeInfo(windows) read %#v, want WMI only", host.readFileCalls) } if want := []fakeHostRunCall{{name: "wmic", args: []string{"os", "get", "LocalDateTime,LastBootUpTime", "/value"}}}; !reflect.DeepEqual(host.runCalls, want) { t.Fatalf("run calls = %#v, want %#v", host.runCalls, want) @@ -356,27 +358,6 @@ func TestCurrentWindowsUptimeSkipsNonWindows(t *testing.T) { } } -func TestCurrentUptimeReturnsZeroForInvalidWindowsWMITimes(t *testing.T) { - t.Parallel() - - host := &fakeHostOS{ - platform: "windows", - runOutputs: map[string]string{ - fakeRunKey("wmic", "os", "get", "LocalDateTime,LastBootUpTime", "/value"): "LocalDateTime=20010201110506+0700\r\nLastBootUpTime=20010201120506+0700\r\n", - }, - } - s := NewSession() - s.host = host - - got := currentUptime(s, time.Now) - if got != 0 { - t.Fatalf("currentUptime(windows invalid times) = %s, want 0", got) - } - if len(host.readFileCalls) != 0 { - t.Fatalf("currentUptime(windows) read %#v, want WMI only", host.readFileCalls) - } -} - func TestCurrentWindowsUptimeInfoMarksInvalidWMITimesUnknown(t *testing.T) { t.Parallel() diff --git a/openspec/changes/add-hugo-github-pages-site/.openspec.yaml b/openspec/changes/archive/2026-07-11-add-hugo-github-pages-site/.openspec.yaml similarity index 100% rename from openspec/changes/add-hugo-github-pages-site/.openspec.yaml rename to openspec/changes/archive/2026-07-11-add-hugo-github-pages-site/.openspec.yaml diff --git a/openspec/changes/add-hugo-github-pages-site/design.md b/openspec/changes/archive/2026-07-11-add-hugo-github-pages-site/design.md similarity index 100% rename from openspec/changes/add-hugo-github-pages-site/design.md rename to openspec/changes/archive/2026-07-11-add-hugo-github-pages-site/design.md diff --git a/openspec/changes/add-hugo-github-pages-site/proposal.md b/openspec/changes/archive/2026-07-11-add-hugo-github-pages-site/proposal.md similarity index 100% rename from openspec/changes/add-hugo-github-pages-site/proposal.md rename to openspec/changes/archive/2026-07-11-add-hugo-github-pages-site/proposal.md diff --git a/openspec/changes/add-hugo-github-pages-site/specs/facts-documentation-site/spec.md b/openspec/changes/archive/2026-07-11-add-hugo-github-pages-site/specs/facts-documentation-site/spec.md similarity index 100% rename from openspec/changes/add-hugo-github-pages-site/specs/facts-documentation-site/spec.md rename to openspec/changes/archive/2026-07-11-add-hugo-github-pages-site/specs/facts-documentation-site/spec.md diff --git a/openspec/changes/add-hugo-github-pages-site/tasks.md b/openspec/changes/archive/2026-07-11-add-hugo-github-pages-site/tasks.md similarity index 100% rename from openspec/changes/add-hugo-github-pages-site/tasks.md rename to openspec/changes/archive/2026-07-11-add-hugo-github-pages-site/tasks.md diff --git a/openspec/changes/add-packages-fact/design.md b/openspec/changes/archive/2026-07-11-add-packages-fact/design.md similarity index 100% rename from openspec/changes/add-packages-fact/design.md rename to openspec/changes/archive/2026-07-11-add-packages-fact/design.md diff --git a/openspec/changes/add-packages-fact/proposal.md b/openspec/changes/archive/2026-07-11-add-packages-fact/proposal.md similarity index 100% rename from openspec/changes/add-packages-fact/proposal.md rename to openspec/changes/archive/2026-07-11-add-packages-fact/proposal.md diff --git a/openspec/changes/add-packages-fact/specs/facts-schema/spec.md b/openspec/changes/archive/2026-07-11-add-packages-fact/specs/facts-schema/spec.md similarity index 100% rename from openspec/changes/add-packages-fact/specs/facts-schema/spec.md rename to openspec/changes/archive/2026-07-11-add-packages-fact/specs/facts-schema/spec.md diff --git a/openspec/changes/add-packages-fact/specs/go-port-supported-platform-facts/spec.md b/openspec/changes/archive/2026-07-11-add-packages-fact/specs/go-port-supported-platform-facts/spec.md similarity index 100% rename from openspec/changes/add-packages-fact/specs/go-port-supported-platform-facts/spec.md rename to openspec/changes/archive/2026-07-11-add-packages-fact/specs/go-port-supported-platform-facts/spec.md diff --git a/openspec/changes/add-packages-fact/tasks.md b/openspec/changes/archive/2026-07-11-add-packages-fact/tasks.md similarity index 100% rename from openspec/changes/add-packages-fact/tasks.md rename to openspec/changes/archive/2026-07-11-add-packages-fact/tasks.md diff --git a/openspec/changes/deepen-discovery-input-surface/.openspec.yaml b/openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/.openspec.yaml similarity index 100% rename from openspec/changes/deepen-discovery-input-surface/.openspec.yaml rename to openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/.openspec.yaml diff --git a/openspec/changes/deepen-discovery-input-surface/design.md b/openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/design.md similarity index 100% rename from openspec/changes/deepen-discovery-input-surface/design.md rename to openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/design.md diff --git a/openspec/changes/deepen-discovery-input-surface/proposal.md b/openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/proposal.md similarity index 100% rename from openspec/changes/deepen-discovery-input-surface/proposal.md rename to openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/proposal.md diff --git a/openspec/changes/deepen-discovery-input-surface/specs/facts-cli-option-contract/spec.md b/openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/specs/facts-cli-option-contract/spec.md similarity index 100% rename from openspec/changes/deepen-discovery-input-surface/specs/facts-cli-option-contract/spec.md rename to openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/specs/facts-cli-option-contract/spec.md diff --git a/openspec/changes/deepen-discovery-input-surface/specs/facts-library-api/spec.md b/openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/specs/facts-library-api/spec.md similarity index 100% rename from openspec/changes/deepen-discovery-input-surface/specs/facts-library-api/spec.md rename to openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/specs/facts-library-api/spec.md diff --git a/openspec/changes/deepen-discovery-input-surface/specs/facts-native-input-surface/spec.md b/openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/specs/facts-native-input-surface/spec.md similarity index 100% rename from openspec/changes/deepen-discovery-input-surface/specs/facts-native-input-surface/spec.md rename to openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/specs/facts-native-input-surface/spec.md diff --git a/openspec/changes/deepen-discovery-input-surface/tasks.md b/openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/tasks.md similarity index 100% rename from openspec/changes/deepen-discovery-input-surface/tasks.md rename to openspec/changes/archive/2026-07-11-deepen-discovery-input-surface/tasks.md diff --git a/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/.openspec.yaml b/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/.openspec.yaml new file mode 100644 index 00000000..68b71747 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-11 diff --git a/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/design.md b/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/design.md new file mode 100644 index 00000000..ea9578ac --- /dev/null +++ b/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/design.md @@ -0,0 +1,88 @@ +## Context + +The core-fact engine has ten unexported functions in production source that have no production callers. Six are test-only adapters beside production-owned seams—five category wrappers plus the `releaseHashFromMatchData` compatibility adapter—and four are standalone utilities whose behavior is asserted only by tests or benchmarks. They survived earlier category and host-probe refactors, so some tests now exercise a contract that production discovery never crosses. + +This is especially misleading in DHCP discovery: the test-only wrapper discards the `matched` state that controls production fallback behavior. Similar wrappers omit uptime provenance, choose a synthetic SSH platform, or duplicate DragonFly DMI coordination in a form that production cannot safely use. + +The cleanup overlaps the completed but unarchived `fix-linux-dhcp-lease-interface-match` change. Implementation begins after that change is archived so its final state-returning semantics and tests are the recorded baseline; the proposals may coexist. + +## Goals / Non-Goals + +**Goals:** + +- Remove the exact ten verified test-only or dead functions. +- Retarget valuable tests to category assembly or pure internal seams that production also invokes. +- Preserve coordination state that the narrower wrappers currently hide. +- Leave the core-fact test surface smaller and more truthful without changing fact behavior. + +**Non-Goals:** + +- Establish a repository-wide dead-code policy or automated dead-code gate. +- Remove pure internal seams that have both production and test callers. +- Refactor category ownership, platform dispatch, or probe implementations. +- Change facts, values, platform gating, diagnostics, public APIs, or CLI behavior. +- Add exported test hooks or new test-only production interfaces. + +## Decisions + +### Delete a fixed, reviewed set instead of every apparent dead function + +The implementation will remove only these functions: + +- `linuxDHClientDHCPServerForInterface` +- `currentUptime` +- `augeasFacts` +- `dragonFlyDMIFacts` +- `sshFacts` +- `rootedPath` +- `bytesToMB` +- `releaseHashFromMatchData` +- `tryToBool` +- `tryToInt` + +Repository-wide dead-code output can include public library entry points, build-target-specific code, and intentional internal seams. A fixed list keeps this change reviewable and avoids turning a local cleanup into a new architectural policy. + +### Retarget wrapper tests to production-owned seams + +Tests that still protect behavior will cross the corresponding production seam: + +- DHCP tests will call `linuxDHClientDHCPServerForInterfaceState` and assert the server, `matched`, and `explicit` results. +- Uptime tests will call `currentUptimeInfo` and preserve duration, `Known`, and fake-host probe/source-selection call assertions. +- Augeas tests will cover `parseAugeasVersion` and `augeasVersionFacts` directly. +- DragonFly DMI coordination will be tested through `currentDragonFlyDMIFacts` with `fakeHostOS`; decoding remains covered through `dragonFlyDMIDecodeFacts`. +- SSH tests will call `sshFactsForPlatform` with an explicit supported platform. +- Release parsing will remain covered through `releaseHashFromString`; match-array assertions unique to the unreachable `releaseHashFromMatchData` adapter will be removed. + +These seams already participate in production discovery. Retargeting therefore improves coverage of the actual control flow instead of introducing replacements for the deleted wrappers. + +### Delete ghost utility contracts without replacements + +`rootedPath`, `bytesToMB`, `tryToBool`, and `tryToInt` have no production caller or production-owned replacement contract. Their dead-contract assertions and the benchmark line that calls `bytesToMB` will be removed rather than moved behind another abstraction. Mixed tests and benchmarks will retain coverage for production-used siblings such as `isSymlink`, `bytesToHumanReadable`, `hertzToHumanReadable`, and `numericValue`. + +### Preserve lazy platform coordination in tests + +DragonFly DMI tests must retain assertions that kenv data short-circuits dmidecode and that dmidecode is used only as fallback. The tests will inject `fakeHostOS` and observe calls rather than eagerly computing a value for a convenience wrapper. This keeps the test faithful to the production side-effect boundary. + +### Sequence after the DHCP change is archived + +The active DHCP change owns the final meaning of the state-returning seam in `networking.go` and its tests. This cleanup will begin after that result is archived and treat it as the recorded baseline rather than restating its behavior. + +## Risks / Trade-offs + +- **Coverage can be lost while deleting contract-only tests.** Each surviving behavioral assertion will first be mapped to a production-owned seam; only assertions for unreachable behavior will disappear. +- **DMI tests could accidentally make dmidecode eager.** Fake-host call counts and the kenv short-circuit scenario will pin the lazy production order. +- **DHCP cleanup could start from an unsettled contract.** Beginning after `fix-linux-dhcp-lease-interface-match` is archived makes its final state-returning semantics the implementation baseline. +- **Static dead-code analysis can produce false positives across targets.** The implementation is constrained to the reviewed list and will verify each name is absent after deletion. + +## Migration Plan + +1. Archive `fix-linux-dhcp-lease-interface-match`. +2. Retarget the useful tests to production-owned seams while preserving their assertions. +3. Delete the ten functions and tests or benchmark cases that exist only for unreachable contracts. +4. Verify the deleted names are absent and run the engine, full, race-sensitive, vet, and build checks. + +Rollback is a normal revert because the change has no persisted data, public API, or user-visible migration. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/proposal.md b/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/proposal.md new file mode 100644 index 00000000..5bf07404 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/proposal.md @@ -0,0 +1,28 @@ +## Why + +Ten unexported helpers in `internal/engine` have no production callers on any supported or candidate target. Six are test-only adapters beside production-owned seams—five category wrappers plus the `releaseHashFromMatchData` compatibility adapter—while four are standalone utilities for behavior that no production path consumes. Both patterns make coverage less truthful and leave obsolete interfaces in production source. + +## What Changes + +- Delete the test-only `linuxDHClientDHCPServerForInterface`, `currentUptime`, `augeasFacts`, `dragonFlyDMIFacts`, `sshFacts`, and `releaseHashFromMatchData` adapters. +- Retarget useful coverage to production-owned category assembly or pure seams, including every valid DHCP match-state tuple, uptime `Known` state and probe-selection order, DragonFly lazy fallback behavior, platform-specific SSH behavior, and release parsing through `releaseHashFromString`. +- Delete the unused `rootedPath`, `bytesToMB`, `tryToBool`, and `tryToInt` utilities together with only the assertions and benchmark cases that specify those dead contracts. +- Strengthen the affected platform-fact regression contract so the production-owned coordination states remain covered during the cleanup. +- Preserve every resolved fact, public Facts interface, CLI byte/status contract, input contract, diagnostic, schema entry, and platform behavior. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `go-port-supported-platform-facts`: Removing obsolete internal test entrances preserves production match state, probe order, fallback behavior, and all supported and candidate platform facts. + +## Impact + +- **Code**: focused deletions and test retargeting in `internal/engine` category files, `core.go`, `factsutil.go`, and their tests. +- **Behavior**: none; this is an in-process test-surface cleanup. +- **Sequencing**: begin implementation after the completed `fix-linux-dhcp-lease-interface-match` change is archived so its final state-returning DHCP semantics and tests are the recorded baseline; the proposals may coexist. +- **Dependencies/docs**: no new dependency, public interface, schema update, or `CHANGELOG.md` entry. diff --git a/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/specs/go-port-supported-platform-facts/spec.md b/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/specs/go-port-supported-platform-facts/spec.md new file mode 100644 index 00000000..6612c2c6 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/specs/go-port-supported-platform-facts/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: Core-fact test-surface cleanup preserves production coordination + +Removing obsolete internal test entrances SHALL NOT change core-fact match state, probe order, fallback behavior, or resolved output. Regression coverage for coordination decisions affected by this cleanup MUST observe production-owned category assembly or pure seams rather than a narrower parallel contract. + +#### Scenario: DHCP match state remains fully covered + +- **WHEN** DHCP lease-interface matching is tested through the production state-returning seam +- **THEN** coverage MUST include `(matched=false, explicit=false)`, `(matched=false, explicit=true)`, and `(matched=true, explicit=true)` +- **AND** the server value MUST be asserted for every relevant lease form, including a matched lease with an empty server value + +#### Scenario: Uptime source selection remains covered + +- **WHEN** uptime behavior is tested after the duration-only wrapper is removed +- **THEN** coverage MUST assert duration and `Known` through the production result +- **AND** fake-host call assertions MUST preserve the platform-specific probe/source-selection order + +#### Scenario: DragonFly DMI fallback remains lazy + +- **WHEN** DragonFly DMI coordination is tested after the convenience wrapper is removed +- **THEN** kenv data MUST short-circuit dmidecode through the production host seam +- **AND** dmidecode MUST remain a lazy fallback when kenv cannot supply the fact + +#### Scenario: Test-surface cleanup preserves supported facts + +- **WHEN** test-only entrances and dead utilities are removed +- **THEN** resolved fact names, values, platform gating, fallback order, and diagnostics MUST remain unchanged on every supported and candidate release target diff --git a/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/tasks.md b/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/tasks.md new file mode 100644 index 00000000..c599645b --- /dev/null +++ b/openspec/changes/archive/2026-07-11-delete-test-only-shadow-interfaces/tasks.md @@ -0,0 +1,22 @@ +## 1. Retarget Behavioral Coverage + +- [x] 1.1 Confirm `fix-linux-dhcp-lease-interface-match` is archived, then retarget DHCP helper tests to `linuxDHClientDHCPServerForInterfaceState`; assert the server for each lease form and all valid `(matched, explicit)` tuples: `(false, false)`, `(false, true)`, and `(true, true)`, including a matched lease with an empty server value. +- [x] 1.2 Retarget uptime tests to `currentUptimeInfo`, preserving duration, `Known`, and fake-host probe/source-selection call assertions. +- [x] 1.3 Preserve Augeas parsing and fact-shaping coverage through `parseAugeasVersion` and `augeasVersionFacts`, eliminating assertions that exist only for the wrapper contract. +- [x] 1.4 Retarget DragonFly DMI coordination tests to `currentDragonFlyDMIFacts` with `fakeHostOS`, preserving kenv short-circuit, dmidecode fallback, and probe call-count assertions. +- [x] 1.5 Retarget SSH fact tests to `sshFactsForPlatform` with explicit supported platforms. +- [x] 1.6 Preserve production release-parsing coverage through `releaseHashFromString` while removing assertions unique to the unreachable match-array adapter. + +## 2. Delete Shadow Interfaces + +- [x] 2.1 Delete `linuxDHClientDHCPServerForInterface`, `currentUptime`, `augeasFacts`, `dragonFlyDMIFacts`, `sshFacts`, and `releaseHashFromMatchData` after their useful coverage crosses production-owned seams or their adapter-only contract is discarded. +- [x] 2.2 Delete `rootedPath`, `bytesToMB`, `tryToBool`, and `tryToInt`; remove only the `rootedPath` assertions from the combined helper test and the `bytesToMB` line from `BenchmarkUnitFormatting`, retaining the `isSymlink`, `bytesToHumanReadable`, and `hertzToHumanReadable` coverage and deleting only dedicated dead-contract tests otherwise. +- [x] 2.3 Search tracked Go source, tests, benchmarks, build-tag variants, and scripts—excluding OpenSpec artifacts and history—to verify the ten names have no remaining executable-source references while the production-owned replacement seams remain covered. +- [x] 2.4 Run `gofmt -w` on every edited Go file. + +## 3. Verify Behavior Preservation + +- [x] 3.1 Run `rtk go test ./internal/engine` and confirm the retargeted tests exercise the production paths. +- [x] 3.2 Run `rtk go test ./...` and `rtk go test -race . ./internal/engine ./internal/app`. +- [x] 3.3 Run `rtk go vet ./...` and `rtk make build`. +- [x] 3.4 Run strict OpenSpec validation for `delete-test-only-shadow-interfaces` and confirm no public API, CLI, schema, changelog, or dependency update is required. diff --git a/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/.openspec.yaml b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/.openspec.yaml new file mode 100644 index 00000000..68b71747 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-11 diff --git a/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/design.md b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/design.md new file mode 100644 index 00000000..8fba73aa --- /dev/null +++ b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/design.md @@ -0,0 +1,93 @@ +## Context + +`Projection` already owns reverse-precedence query selection, wildcard matching, canonical-tree fallback, dotted external/registered fact mode, output shape, selected-query values, and strict missing-query classification. The remaining seam is raw: the internal Snapshot stores a Projection but exposes `Snapshot.Facts()` so `internal/app` can pass `[]ResolvedFact` to the formatter, inspect query names for timing, and construct another Projection for strict mode. Formatter adapters then construct a Projection from those records. + +This is an in-process, behavior-preserving deepening. It must respect four distinct Projection lifetimes rather than pretending one object can serve every contract: + +1. Discovery-time Projections select queries before and after persistent cache resolution, using the discovery plan's dotted-fact mode. +2. The canonical Snapshot Projection always uses the canonical non-force-dot tree and memoizes that tree for public `Value` lookups. +3. The normal CLI presentation Projection uses the CLI/config force-dot mode and a defensive copy of the Snapshot's selected records. +4. The version-query fast path has no Snapshot and builds a synthetic one-fact presentation Projection before using the normal formatter-selection seam. + +The active discovery-input change keeps formatter selection, timing output, strict diagnostics/status, and the version fast-path decision in `internal/app`. This change preserves that ownership and does not duplicate its capability delta while the change remains active. + +## Goals / Non-Goals + +**Goals:** + +- Hide raw Snapshot records behind one defensive presentation Projection seam. +- Keep raw-returning query selection package-private so `internal/app` cannot recover the retired record seam from that Projection. +- Make formatter adapters consume a prebuilt Projection rather than reconstructing projection semantics from `[]ResolvedFact`. +- Reuse one normal-query presentation Projection for formatting, timing names, and strict missing-query classification. +- Keep Projection-owned shape, selected-value, query-name, and missing-query logic local to the Projection module. +- Preserve Snapshot immutability and every existing public and CLI contract. + +**Non-Goals:** + +- No attempt to collapse discovery selection, canonical Snapshot, CLI presentation, or version-fast-path Projections into one object or one computation. +- No public Facts API or public Snapshot method change. +- No change to discovery planning, cache ordering, `ResolvedFact` producers, source precedence, canonical-tree semantics, query grammar, or force-dot behavior. +- No move of formatter selection, timing rendering, strict diagnostics/status, or fast-path decisions out of `internal/app`. +- No universal renderer value that erases legitimate JSON/YAML versus HOCON/legacy scalar and container differences. +- No new formatter abstraction, dependency, ADR, format, fact, or schema entry. + +## Decisions + +**1. Snapshot will expose a defensive output Projection, not raw facts.** + +Add an internal-engine method callable by `internal/app`, conceptually `OutputProjection(includeTypedDotted bool)`, that creates a Projection over a deep clone of the Snapshot's selected facts. Remove `Snapshot.Facts()` once its callers and defensive-copy tests migrate. + +The output Projection must not return or share the canonical Snapshot's mutable maps, slices, pointers, or exported struct fields. Returning the Snapshot's existing Projection directly was rejected because formatter code and user-defined values must not be able to mutate Snapshot state. Reusing the canonical Projection for force-dot output was also rejected because the canonical tree deliberately has different dotted-fact semantics. + +**2. Formatter adapters will consume a Projection.** + +Change the formatter seam from `Format([]ResolvedFact)` to formatting a prebuilt Projection. Dotted-fact mode moves out of formatter options because it is already fixed by the presentation Projection. JSON, YAML, HOCON, and legacy adapters retain their format-specific rendering and byte-level quirks; they read shape and values from Projection instead of constructing one. + +`Formatter` currently has one concrete `formatterFunc` implementation and a `Name` method used only by tests. Keep `BuildFormatter` as the formatter-selection boundary required by the CLI contract, but return and invoke a concrete formatter function/type instead of retaining the one-implementation interface. Selection tests will pin output and errors rather than a test-only name method. + +Keeping raw-fact compatibility overloads was rejected because it would preserve the shallow seam. Moving format-specific scalar/container behavior into Projection was rejected because that would couple the domain module to presentation formats. + +**3. Projection will expose only the additional view needed by the CLI adapter.** + +Strict missing-query detection will operate on the Projection's own backing selection rather than accepting a second `[]ResolvedFact`. Projection will also provide the ordered presentation names needed by timing output: each selected `UserQuery`, falling back to the fact `Name` for full output. Shape and selected-query-map helpers currently owned by the formatter file will move beside Projection. + +The current raw-returning `Projection.Select` operation will become package-private because only discovery and canonical lookup inside `internal/engine` need it. Normal CLI and formatter paths will use only presentation shape, value, name, and missing-query views. A structural search will reject `Snapshot.Facts()`, exported raw selection, or `[]ResolvedFact` in those normal presentation paths; the version fast path remains the sole documented app-side exception because it creates a new synthetic fact rather than escaping Snapshot records. + +Receiver-owned missing classification will retain its existing CLI-specific semantics: selected registered/external facts resolved to nil are missing even though public Snapshot lookup treats them as found; missing names preserve selection order and duplicates; full-output records with an empty `UserQuery` are ignored. Ordered presentation names likewise visit every selected record in order, retain duplicates, and use `UserQuery` before falling back to `Name`. + +Projection shape will keep the current formatter contract: no records are empty output; full-output records with empty `UserQuery` form a full tree; one distinct non-empty query is scalar even if repeated; and multiple distinct queries form a query map. Format adapters retain their existing empty and scalar bytes. + +Exposing raw records or a general record iterator was rejected because timing and strict mode do not need values, source type, or file metadata. + +**4. The CLI will create one normal presentation Projection and retain process-edge ownership.** + +After `Discover`, `internal/app` obtains one presentation Projection using the effective force-dot setting. It passes that Projection to the selected formatter, reads its ordered names for timing lines, and asks it for missing queries before emitting the existing strict diagnostics and status. Output ordering remains unchanged: timing lines, formatted output, then strict diagnostics/status. + +**5. The version fast path remains a separate synthetic adapter.** + +The fast path will construct a one-fact Projection for `facterversion` and pass it through `BuildFormatter`. It continues to ignore color and force-dot options, bypass full discovery only under the existing eligibility rules, and preserve all byte and fall-through behavior. A new one-use `VersionProjection` helper was rejected; the synthetic record is not a Snapshot escape and does not justify another interface. + +## Risks / Trade-offs + +- **Snapshot state is accidentally shared with presentation** -> Build the presentation Projection over the existing deep-clone path and migrate the current map, pointer, alias, and cycle defensive-copy tests. +- **Canonical and force-dot modes are conflated** -> Test that force-dot presentation changes selected/output projection only and never changes `Snapshot.Tree` or `Snapshot.Value`. +- **Formatter output drifts during signature migration** -> Keep byte-pinned JSON, YAML, HOCON, legacy, color, nil, empty, single-query, and multi-query tests at the formatter and CLI contract surfaces. +- **Timing names, duplicate queries, shape, or strict ordering changes** -> Add focused Projection and CLI tests that pin record-order timing names, duplicate handling, empty/full/scalar/map shape, missing-query diagnostics, and status after the shared Projection migration. +- **Tests preserve the retired raw seam through compatibility helpers** -> Migrate formatter tests to construct Projections explicitly; do not add production or test-only raw-fact formatter overloads. +- **The one-implementation formatter interface survives only for its test-only name method** -> Preserve `BuildFormatter`, collapse the interface to a concrete callable type, and make selection tests assert behavior. + +## Migration Plan + +1. Archive `deepen-discovery-input-surface` so its CLI ownership and input-surface requirements are the baseline for this change. +2. Add Projection and Snapshot tests for ordered presentation names, receiver-owned missing queries, duplicate/shape semantics, distinct dotted modes, and defensive output Projection copies. +3. Make raw-returning selection package-private; change formatter adapters and their tests to consume presentation views from Projection while preserving format-specific rendering; and collapse the one-implementation formatter interface while retaining `BuildFormatter`. +4. Change `internal/app` normal query handling to obtain and reuse one presentation Projection for formatting, timing names, and strict classification. +5. Adapt the synthetic version fast path to the Projection formatter seam and retain its byte and fall-through pins. +6. Remove `Snapshot.Facts()`, exported raw selection, raw formatter signatures, the one-implementation formatter interface and test-only name method, obsolete dotted formatter options, and misplaced Projection helpers after no callers remain. +7. Run focused tests, the full suite, the concurrency-sensitive race suite, `go vet`, `make build`, and strict OpenSpec validation. + +Rollback is a source revert because there is no persisted data or external migration. + +## Open Questions + +(none) diff --git a/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/proposal.md b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/proposal.md new file mode 100644 index 00000000..69ad7105 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/proposal.md @@ -0,0 +1,29 @@ +## Why + +Query semantics are centralized in `Projection`, but the Snapshot-to-CLI formatter seam still exposes `[]ResolvedFact`: `internal/app` reads those records for formatting and timing, formatter adapters reconstruct a Projection, and strict mode constructs another one. This leaves the archived query-output projection design incomplete and exposes discovery metadata where callers need only a presentation view. + +## What Changes + +- Replace the internal `Snapshot.Facts()` escape with a defensive presentation Projection that hides raw resolved-fact records from the CLI formatter seam. +- Make formatter adapters consume an existing Projection, and let one normal-query presentation Projection provide formatter shape/value data, timing names, and strict missing-query classification. +- Make raw-record query selection package-private and collapse the one-implementation formatter interface while preserving `BuildFormatter` as the CLI construction seam. +- Keep discovery-time query selection, the canonical Snapshot Projection, the CLI presentation Projection, and the synthetic version-fast-path Projection distinct because they have different lifetimes and dotted-fact modes. +- Preserve the public Facts API, Snapshot immutability, canonical-tree behavior, force-dot behavior, formatter bytes, CLI stdout/stderr and status behavior, cache ordering, and the version-query fast path. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `go-port-framework-parity`: Formatter adapters must consume projection-shaped input without rebuilding query semantics from raw resolved facts, while preserving every output format contract. +- `facts-library-api`: Internal Snapshot presentation access must preserve the Snapshot's defensive-copy and immutable-result guarantees without changing its public API. + +## Impact + +- **Code**: `internal/engine/projection.go`, `internal/engine/snapshot.go`, `internal/engine/formatter.go`, `internal/app/app.go`, and focused projection, Snapshot, formatter, and CLI contract tests. +- **Behavior**: Behavior-preserving refactor; any public API, output byte, diagnostic, status, dotted-fact, timing, strict-mode, or version-fast-path difference is a bug. +- **Sequencing**: apply after the completed `deepen-discovery-input-surface` change is archived, because it establishes the CLI ownership boundary and overlaps `internal/app`, force-dot behavior, and query projection. +- **Dependencies/docs/schema**: No new dependency, ADR, changelog entry, fact schema update, or platform-specific validation is expected. diff --git a/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/specs/facts-library-api/spec.md b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/specs/facts-library-api/spec.md new file mode 100644 index 00000000..7b6d5624 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/specs/facts-library-api/spec.md @@ -0,0 +1,36 @@ +## MODIFIED Requirements + +### Requirement: Canonical tree queries and generic decode +A Snapshot SHALL expose the canonical tree — the same fact names, nesting, and value normalization the output contract pins — through pure query operations using Facter dot-notation, and a generic decode (`facts.As[T]`) SHALL convert any queried subtree into a caller-supplied type. Decode MUST read from the resolved canonical tree and MUST NOT resolve facts independently. Snapshot value lookup SHALL use the same internal projection semantics as CLI query projection where their contracts overlap, while preserving the library distinction between missing facts and resolved nil registered/external facts. Internal presentation consumers MUST receive a defensive Projection rather than raw Snapshot records, and that Projection MUST NOT expand the public Snapshot API or permit mutation of Snapshot state. + +#### Scenario: Dotted query resolution +- **WHEN** a consumer queries `snapshot.Value("os.release.major")` +- **THEN** the returned value equals the corresponding node of the canonical tree, identical to the value the CLI would report for the same query + +#### Scenario: Generic decode of any fact kind +- **WHEN** a consumer decodes a core, registered, or external fact subtree into a matching struct type via `facts.As[T]` +- **THEN** the populated value reflects the canonical tree, regardless of which source (core, registered, external) won precedence + +#### Scenario: Decode shape mismatch fails loudly +- **WHEN** an operator-supplied fact has reshaped a name (e.g. an external fact redefines `os` as a string) and a consumer decodes it into an incompatible type +- **THEN** `facts.As[T]` returns a non-nil error describing the mismatch and never returns a partially or silently coerced value + +#### Scenario: Presentation cannot mutate a Snapshot +- **WHEN** the internal CLI adapter formats a Snapshot through a presentation Projection and formatter or custom-value code mutates a returned map, slice, pointer, array, or exported struct field +- **THEN** subsequent `Snapshot.Value`, `Snapshot.Tree`, `Snapshot.All`, and `facts.As[T]` calls MUST observe the original immutable Snapshot values + +#### Scenario: Internal presentation boundary hides resolved records +- **WHEN** `internal/app` obtains a Snapshot's defensive presentation Projection +- **THEN** no Snapshot method or app-visible Projection selection or iterator operation SHALL expose its backing `[]ResolvedFact` records +- **AND** normal app and formatter paths MUST consume only Projection shape, value, name, and missing-query views +- **AND** the version-query fast path MAY construct its separate synthetic resolved fact solely to build its independent presentation Projection + +#### Scenario: Force-dot presentation does not replace the canonical tree +- **WHEN** the CLI requests force-dot presentation for dotted external or registered facts +- **THEN** the internal presentation Projection MAY merge those dotted facts for query/output behavior +- **AND** the Snapshot's canonical tree and public query/decode results MUST retain the existing non-force-dot semantics + +#### Scenario: Public Snapshot surface remains unchanged +- **WHEN** the internal raw-fact formatter escape is replaced by a presentation Projection +- **THEN** the public Snapshot SHALL continue to expose only its existing canonical tree, value lookup, ordered iteration, and generic decode operations +- **AND** no public raw resolved-fact or Projection accessor SHALL be added diff --git a/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/specs/go-port-framework-parity/spec.md b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/specs/go-port-framework-parity/spec.md new file mode 100644 index 00000000..da91451f --- /dev/null +++ b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/specs/go-port-framework-parity/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: Config, cache, query, formatter, and logging parity +The Go port SHALL preserve Ruby-compatible framework behavior around configuration, cache groups, fact filtering, query selection, formatting, and diagnostics. Selected-query projection, dotted fact mode, selected-query values, nil rendering, presentation names, and strict missing-fact detection SHALL be centralized behind one internal projection module. Normal CLI formatter, timing-name, and strict-mode paths MUST consume one presentation Projection rather than receiving raw resolved-fact records and rebuilding those rules independently. + +#### Scenario: Configuration and cache behavior +- **WHEN** Facter reads config files, fact groups, TTLs, blocklists, default paths, invalid config, unreadable config, cache files, expired cache, corrupt cache, and external fact cache groups +- **THEN** the Go port MUST match Ruby behavior for accepted syntax, warnings/errors, cache reads, cache writes, cache invalidation, permission failures, unsupported cache cases, and blocklist expansion + +#### Scenario: Query and formatter behavior +- **WHEN** Facter formats selected or unselected facts as legacy text, JSON, YAML, or HOCON +- **THEN** the Go port MUST match Ruby behavior for nested fact selection, arrays, dotted fact names, nil rendering, scalar formatting, map ordering where specified, string quoting, IPv6/path handling, and collision diagnostics +- **AND** selected-query projection, dotted fact mode, selected-query value maps, and strict missing-query detection MUST be provided by the internal projection module rather than duplicated across CLI and formatter paths +- **AND** formatter adapters MUST consume shape and selected values from a presentation Projection rather than reconstructing a Projection from raw Snapshot records +- **AND** each formatter MAY retain the format-specific scalar, map, ordering, quoting, and legacy transformation rules required for byte compatibility + +#### Scenario: Normal CLI presentation reuses one projection +- **WHEN** the CLI completes normal discovery and then formats output, emits timing names, or classifies missing queries for strict mode +- **THEN** those paths MUST consume one defensive presentation Projection configured with the effective dotted-fact mode +- **AND** timing rendering, output routing, strict diagnostics, and strict exit status MUST remain owned by the CLI adapter + +#### Scenario: Strict missing-query classification keeps CLI semantics +- **WHEN** the presentation Projection classifies selected records for strict mode +- **THEN** a selected registered or external fact resolved to nil MUST be reported missing even though public Snapshot lookup treats that resolved nil as found +- **AND** missing query names MUST retain selection order and duplicates +- **AND** full-output records with an empty `UserQuery` MUST NOT be reported missing + +#### Scenario: Presentation names retain record order +- **WHEN** timing output asks the presentation Projection for display names +- **THEN** it MUST return one name for every selected record in original order, including duplicate queries +- **AND** each name MUST use `UserQuery` when non-empty and otherwise fall back to the resolved fact `Name` + +#### Scenario: Presentation shape retains formatter semantics +- **WHEN** a formatter receives an empty selection, full-output records, one distinct selected query, repeated copies of one query, or multiple distinct queries +- **THEN** Projection MUST classify them respectively as empty, full-tree, scalar, scalar, or query-map output +- **AND** every formatter MUST preserve its existing empty-output, scalar, nil, and container bytes + +#### Scenario: Projection lifetimes remain distinct +- **WHEN** discovery selects queries, a Snapshot serves canonical lookup, the CLI applies force-dot presentation, or the version-query fast path renders its synthetic fact +- **THEN** each path MUST use a Projection with the lifetime and dotted-fact mode required by that path +- **AND** the implementation MUST NOT reuse a force-dot presentation tree as the canonical Snapshot tree + +#### Scenario: Logging and diagnostics +- **WHEN** framework code emits debug, info, warning, error, once-only, exception, HTTP debug, timing, strict missing-fact, parser, resolver, cache, or external fact diagnostics +- **THEN** the Go port MUST match Ruby-compatible message text, severity, once-only semantics, and stderr routing for in-scope CLI behaviors, with the program name token rebranded from `Facter` to `Facts` (ADR-0008) diff --git a/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/tasks.md b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/tasks.md new file mode 100644 index 00000000..96245090 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-finish-query-output-projection-seam/tasks.md @@ -0,0 +1,42 @@ +## 1. Prerequisite and Projection Contract Tests + +- [x] 1.1 Confirm `deepen-discovery-input-surface` is archived and use its CLI ownership and input-surface requirements as the implementation baseline. +- [x] 1.2 Add Projection tests proving receiver-owned strict classification treats selected nil registered/external facts as missing, ignores empty-`UserQuery` full output, and preserves missing-query order and duplicates without using public `LookupValue` semantics. +- [x] 1.3 Replace the internal `Snapshot.Facts()` defensive-copy tests with output-Projection tests covering maps, slices, arrays, pointers, exported struct fields, shared aliases, and cycles. +- [x] 1.4 Add tests proving the canonical Snapshot Projection remains non-force-dot while a defensive CLI presentation Projection can use force-dot semantics without changing `Snapshot.Value`, `Snapshot.Tree`, or `Snapshot.All`. +- [x] 1.5 Add Projection tests proving presentation names retain every backing record in order and duplicates with `UserQuery`-then-`Name` fallback, and pin empty, full-tree, one-distinct-query scalar, repeated-query scalar, and multi-distinct-query map shapes. + +## 2. Projection and Snapshot Seam + +- [x] 2.1 Make raw-returning query selection package-private; make `Projection.MissingQueries` operate on its backing selection; add the narrow ordered-name view required by timing output; and move Projection-owned shape/query-map helpers out of the formatter module. +- [x] 2.2 Add the internal Snapshot output-Projection seam over a deep clone of selected facts, preserving the existing defensive-copy guarantee for mutable and cyclic values. +- [x] 2.3 Keep discovery-time selection, canonical Snapshot, CLI presentation, and version-fast-path Projections distinct, with the dotted-fact mode and lifetime documented for each path. + +## 3. Formatter Migration + +- [x] 3.1 Migrate formatter factory and adapter tests to construct and pass Projections for empty, full-tree, single-query, repeated-query, multi-query, nil, dotted, colorized, and machine-format cases without adding raw-fact compatibility overloads or asserting a test-only formatter name. +- [x] 3.2 Change the formatter seam to a concrete callable formatter type that consumes a prebuilt Projection; retain `BuildFormatter`, remove the one-implementation `Formatter` interface and dotted-fact formatter option, and keep JSON, YAML, HOCON, and legacy format-specific shape and byte-rendering rules local to their adapters. +- [x] 3.3 Preserve all existing formatter byte pins, error wrapping, factory precedence, map ordering, quoting, scalar rendering, legacy transformations, and color behavior. + +## 4. CLI Presentation Wiring + +- [x] 4.1 Change the normal query path to obtain one defensive presentation Projection from the Snapshot and reuse it for formatter input, timing names, and strict missing-query classification. +- [x] 4.2 Add focused CLI tests that pin ordered nested-query timing lines, formatted output ordering, strict diagnostics, and strict exit status through the shared presentation Projection. +- [x] 4.3 Adapt the version-query fast path to construct its separate synthetic one-fact Projection and continue routing through `BuildFormatter`, preserving all existing format bytes, eligibility, ignored color/force-dot options, and disabled/external/timing fall-through behavior. +- [x] 4.4 Keep formatter selection, stdout/stderr writes, timing rendering, strict diagnostics/status, and the version-fast-path decision in `internal/app`. + +## 5. Cleanup + +- [x] 5.1 Remove `Snapshot.Facts()`, raw-fact formatter signatures, obsolete formatter dotted-mode plumbing, and superseded tests only after all production callers use the Projection seam. +- [x] 5.2 Confirm normal `internal/app` and formatter paths contain no `Snapshot.Facts`, app-visible raw selection/iterator, `Formatter` interface, test-only formatter `Name`, or `[]ResolvedFact` seam; allow only the documented synthetic version record and internal-engine discovery selection. +- [x] 5.3 Confirm no production or test-only formatter overload reconstructs Projection from `[]ResolvedFact` merely to preserve the retired seam. +- [x] 5.4 Run `gofmt -w` on every edited Go file and confirm no public Facts API, changelog, fact schema, ADR, dependency, discovery planner, cache ordering, query grammar, or platform code change is present. + +## 6. Verification + +- [x] 6.1 Run `rtk go test ./internal/engine ./internal/app .` for the focused engine, CLI adapter, and root Snapshot/library contracts. +- [x] 6.2 Run `rtk go test ./...`. +- [x] 6.3 Run `rtk go test -race . ./internal/engine ./internal/app`. +- [x] 6.4 Run `rtk go vet ./...`. +- [x] 6.5 Run `rtk make build`. +- [x] 6.6 Run `rtk openspec validate finish-query-output-projection-seam --strict`. diff --git a/openspec/changes/fix-linux-dhcp-lease-interface-match/proposal.md b/openspec/changes/archive/2026-07-11-fix-linux-dhcp-lease-interface-match/proposal.md similarity index 100% rename from openspec/changes/fix-linux-dhcp-lease-interface-match/proposal.md rename to openspec/changes/archive/2026-07-11-fix-linux-dhcp-lease-interface-match/proposal.md diff --git a/openspec/changes/archive/2026-07-11-fix-linux-dhcp-lease-interface-match/specs/go-port-supported-platform-facts/spec.md b/openspec/changes/archive/2026-07-11-fix-linux-dhcp-lease-interface-match/specs/go-port-supported-platform-facts/spec.md new file mode 100644 index 00000000..30fa3ebf --- /dev/null +++ b/openspec/changes/archive/2026-07-11-fix-linux-dhcp-lease-interface-match/specs/go-port-supported-platform-facts/spec.md @@ -0,0 +1,76 @@ +## MODIFIED Requirements + +### Requirement: Core fact parity + +The Go port SHALL expose Ruby-compatible structured facts for each supported platform where Ruby Facter has comparable behavior, except for the intentionally removed Ruby runtime and Puppet package-version built-ins. Legacy alias facts are not part of the surface. Facts MAY expose Facts-native extensions when the native source is stable, the canonical fact spelling is schema-documented, and platform validation covers the behavior. Linux DHCP lease attribution for interface-level networking facts MUST use the exact interface and lease-block semantics specified below. + +#### Scenario: Linux fact parity +- **WHEN** Linux facts are resolved for OS/release/distro, SELinux, identity, networking, DHCP, memory, swap, processors, DMI, disks, partitions, filesystems, mountpoints, uptime, load averages, virtualization, hypervisors, cloud metadata, SSH, timezone, path, FIPS, and Augeas +- **THEN** the Go port MUST match Ruby structured fact names, values, nil behavior, fallback precedence, diagnostics, and formatted output for supported Linux behavior, while omitting `ruby`, `aio_agent_version`, and Puppet package-version facts + +#### Scenario: macOS fact parity +- **WHEN** macOS/Darwin facts are resolved for OS/release, macOS product/build/version, identity, networking, DHCP, memory, swap, processors, DMI, system profiler hardware/software/ethernet, filesystems, mountpoints, uptime, load averages, virtualization, SSH, timezone, path, and Augeas +- **THEN** the Go port MUST match Ruby structured fact names, values, nil behavior, command parsing, fallback precedence, diagnostics, and formatted output for supported macOS behavior, while omitting `ruby`, `aio_agent_version`, and Puppet package-version facts + +#### Scenario: Windows fact parity +- **WHEN** Windows facts are resolved for OS/release/product metadata, system32 path, identity, networking, DHCP, memory, processors, DMI, kernel, FIPS, virtualization, hypervisors, cloud metadata, SSH, uptime, timezone, and path +- **THEN** the Go port MUST match Ruby structured fact names, values, nil behavior, WMI/registry parsing, fallback precedence, and diagnostic messages for supported Windows behavior, while omitting `aio_agent_version` and Puppet package-version facts + +#### Scenario: FreeBSD fact parity +- **WHEN** FreeBSD facts are resolved for OS/release, identity, networking, memory, swap, processors, DMI, disks, partitions, mountpoints, uptime, load averages, virtualization, SSH, timezone, path, Augeas, ZFS, and Zpool +- **THEN** the Go port MUST match Ruby structured fact names, values, nil behavior, sysctl/geom/mount/df/parser behavior, fallback precedence, diagnostics, and formatted output for supported FreeBSD behavior (Ruby Facter resolves no `filesystems` fact on FreeBSD, so it is absent per the not-applicable rule), while omitting `ruby`, `aio_agent_version`, and Puppet package-version facts + +#### Scenario: OpenBSD fact parity +- **WHEN** OpenBSD facts are resolved for OS/release, identity, networking, memory, swap, processors, DMI when available, disks, partitions, mountpoints, uptime, load averages, virtualization, SSH, timezone, path, and Augeas +- **THEN** the Go port MUST match Ruby structured fact names, values, nil behavior, sysctl/disklabel/mount/df/route/dhcpleasectl parser behavior, fallback precedence, diagnostics, and formatted output for supported OpenBSD behavior, while omitting `ruby`, `aio_agent_version`, Puppet package-version facts, legacy aliases, ZFS/zpool facts, and other platform-inapplicable facts + +#### Scenario: NetBSD fact parity +- **WHEN** NetBSD facts are resolved for OS/release, identity, networking, memory, swap, processors, DMI when available, disks, partitions, mountpoints, uptime, load averages, virtualization, SSH, timezone, path, Augeas, and conditional ZFS/zpool command output +- **THEN** the Go port MUST match Ruby structured fact names, values, nil behavior, sysctl/disklabel/dkctl/mount/df/route/parser behavior, fallback precedence, diagnostics, and formatted output for supported NetBSD behavior, while omitting `ruby`, `aio_agent_version`, Puppet package-version facts, legacy aliases, and platform-inapplicable or unusable ZFS/zpool facts + +#### Scenario: DragonFly fact coverage +- **WHEN** DragonFly BSD facts are resolved for OS/release, identity, networking, memory, swap, processors, disks, partitions, mountpoints, uptime, load averages, virtualization, SSH, timezone, path, and other audited native sources +- **THEN** the Go port MUST emit only schema-documented DragonFly facts backed by stable DragonFly sources and native validation, reporting `DragonFly` for `os.name`, `os.family`, and `kernel.name` +- **AND** Ruby Facter byte parity MUST NOT block accurate Facts-native extensions when DragonFly has no comparable Facter behavior + +#### Scenario: illumos fact coverage +- **WHEN** illumos facts are resolved on OmniOS for OS/release, identity, networking, memory, swap, processors, disks, partitions, mountpoints, uptime, load averages, virtualization/zones where available, SSH, timezone, path, ZFS, Zpool, and other audited native sources +- **THEN** the Go port MUST emit only schema-documented illumos facts backed by stable illumos sources and native validation +- **AND** `os.family` MUST be `illumos`, `kernel.name` MUST be `SunOS`, and `os.name` MUST report the validated distribution such as `OmniOS` +- **AND** Oracle Solaris-specific behavior MUST remain absent until the Oracle Solaris target is separately validated + +#### Scenario: Linux DHCP lease interface declarations match exactly +- **WHEN** Linux DHCP lease files are scanned for `networking.interfaces..dhcp` +- **AND** a lease file contains one or more explicit dhclient `interface "..."` declarations +- **THEN** Facts MUST use that lease only when one non-comment declaration exactly equals the requested interface name +- **AND** when a file contains multiple lease blocks, Facts MUST extract the DHCP server from the matching interface block rather than from a later block for another interface +- **AND** Facts MUST parse lease block boundaries without treating braces inside comments or quoted strings as lease terminators +- **AND** if a malformed lease block has no terminator or contains an unterminated quoted string, Facts MUST continue scanning later valid lease blocks rather than falling back to a whole-file DHCP server from another interface +- **AND** malformed interface quoted values MUST NOT count as explicit interface declarations or suppress lease filename fallback +- **AND** Facts MUST recognize explicit interface declarations even when dhclient writes multiple statements on one line +- **AND** when an exact interface declaration appears outside the lease block, Facts MUST still use the file-level DHCP server identifier for that interface +- **AND** when a file-level interface declaration matches and lease blocks omit per-block interface declarations, Facts MUST use the latest DHCP server identifier from those historical leases +- **AND** when multiple lease blocks exactly match the requested interface, the latest matching block MUST control the DHCP server value even if it omits `dhcp-server-identifier` +- **AND** commented or quoted `dhcp-server-identifier` text MUST NOT count as a DHCP server option +- **AND** explicit lease blocks for other interfaces MUST NOT override a file-level declaration for the requested interface +- **AND** Facts MUST NOT treat interface names that merely contain the requested name, such as `eth0-backup` for `eth0`, as a match +- **AND** lease filename fallback MAY still apply when a lease file has no explicit interface declaration + +#### Scenario: YAML sequence map values preserve all keys +- **WHEN** YAML output renders a sequence item whose value is a map with multiple keys +- **THEN** Facts MUST render that map as valid YAML that preserves every key/value pair in the sequence item +- **AND** the sequence item MUST NOT collapse the map into a scalar value for the first key + +#### Scenario: Plan 9 uptime duration fields use shared numeric types +- **WHEN** Plan 9 uptime facts are emitted +- **THEN** `system_uptime.days`, `system_uptime.hours`, and `system_uptime.seconds` MUST use 64-bit integer values +- **AND** those fields MUST match the numeric value types emitted by other supported platforms + +#### Scenario: Snapshot accessors clone public mutable values +- **WHEN** a Snapshot contains a mutable public fact value, including maps, slices, pointers, arrays, and exported struct fields +- **THEN** Snapshot construction, value lookup, and copy-returning accessors MUST clone mutable values and pointed-to values in that public graph +- **AND** mutating the source value or a returned value MUST NOT mutate the Snapshot +- **AND** maps with pointer-bearing keys MUST NOT expose original key pointers when a copied key remains valid for the map key type +- **AND** cyclic pointer, map, and slice values MUST preserve cycles inside the copied graph without linking back to the original graph +- **AND** distinct slices that share backing storage MUST remain distinct copies when their visible lengths differ +- **AND** unexported struct fields MAY be preserved by shallow value copy and are not part of the deep-clone guarantee diff --git a/openspec/changes/fix-linux-dhcp-lease-interface-match/tasks.md b/openspec/changes/archive/2026-07-11-fix-linux-dhcp-lease-interface-match/tasks.md similarity index 100% rename from openspec/changes/fix-linux-dhcp-lease-interface-match/tasks.md rename to openspec/changes/archive/2026-07-11-fix-linux-dhcp-lease-interface-match/tasks.md diff --git a/openspec/changes/optimize-bsd-disks-probe/design.md b/openspec/changes/archive/2026-07-11-optimize-bsd-disks-probe/design.md similarity index 100% rename from openspec/changes/optimize-bsd-disks-probe/design.md rename to openspec/changes/archive/2026-07-11-optimize-bsd-disks-probe/design.md diff --git a/openspec/changes/optimize-bsd-disks-probe/proposal.md b/openspec/changes/archive/2026-07-11-optimize-bsd-disks-probe/proposal.md similarity index 100% rename from openspec/changes/optimize-bsd-disks-probe/proposal.md rename to openspec/changes/archive/2026-07-11-optimize-bsd-disks-probe/proposal.md diff --git a/openspec/changes/optimize-bsd-disks-probe/specs/go-port-supported-platform-facts/spec.md b/openspec/changes/archive/2026-07-11-optimize-bsd-disks-probe/specs/go-port-supported-platform-facts/spec.md similarity index 100% rename from openspec/changes/optimize-bsd-disks-probe/specs/go-port-supported-platform-facts/spec.md rename to openspec/changes/archive/2026-07-11-optimize-bsd-disks-probe/specs/go-port-supported-platform-facts/spec.md diff --git a/openspec/changes/optimize-bsd-disks-probe/tasks.md b/openspec/changes/archive/2026-07-11-optimize-bsd-disks-probe/tasks.md similarity index 100% rename from openspec/changes/optimize-bsd-disks-probe/tasks.md rename to openspec/changes/archive/2026-07-11-optimize-bsd-disks-probe/tasks.md diff --git a/openspec/changes/fix-linux-dhcp-lease-interface-match/specs/go-port-supported-platform-facts/spec.md b/openspec/changes/fix-linux-dhcp-lease-interface-match/specs/go-port-supported-platform-facts/spec.md deleted file mode 100644 index 69ff02a7..00000000 --- a/openspec/changes/fix-linux-dhcp-lease-interface-match/specs/go-port-supported-platform-facts/spec.md +++ /dev/null @@ -1,41 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Core fact parity - -The Go port SHALL expose Ruby-compatible structured facts for each supported platform where Ruby Facter has comparable behavior, including correct Linux DHCP lease attribution for interface-level networking facts. - -#### Scenario: Linux DHCP lease interface declarations match exactly -- **WHEN** Linux DHCP lease files are scanned for `networking.interfaces..dhcp` -- **AND** a lease file contains one or more explicit dhclient `interface "..."` declarations -- **THEN** Facts MUST use that lease only when one non-comment declaration exactly equals the requested interface name -- **AND** when a file contains multiple lease blocks, Facts MUST extract the DHCP server from the matching interface block rather than from a later block for another interface -- **AND** Facts MUST parse lease block boundaries without treating braces inside comments or quoted strings as lease terminators -- **AND** if a malformed lease block has no terminator or contains an unterminated quoted string, Facts MUST continue scanning later valid lease blocks rather than falling back to a whole-file DHCP server from another interface -- **AND** malformed interface quoted values MUST NOT count as explicit interface declarations or suppress lease filename fallback -- **AND** Facts MUST recognize explicit interface declarations even when dhclient writes multiple statements on one line -- **AND** when an exact interface declaration appears outside the lease block, Facts MUST still use the file-level DHCP server identifier for that interface -- **AND** when a file-level interface declaration matches and lease blocks omit per-block interface declarations, Facts MUST use the latest DHCP server identifier from those historical leases -- **AND** when multiple lease blocks exactly match the requested interface, the latest matching block MUST control the DHCP server value even if it omits `dhcp-server-identifier` -- **AND** commented or quoted `dhcp-server-identifier` text MUST NOT count as a DHCP server option -- **AND** explicit lease blocks for other interfaces MUST NOT override a file-level declaration for the requested interface -- **AND** Facts MUST NOT treat interface names that merely contain the requested name, such as `eth0-backup` for `eth0`, as a match -- **AND** lease filename fallback MAY still apply when a lease file has no explicit interface declaration - -#### Scenario: YAML sequence map values preserve all keys -- **WHEN** YAML output renders a sequence item whose value is a map with multiple keys -- **THEN** Facts MUST render that map as valid YAML that preserves every key/value pair in the sequence item -- **AND** the sequence item MUST NOT collapse the map into a scalar value for the first key - -#### Scenario: Plan 9 uptime duration fields use shared numeric types -- **WHEN** Plan 9 uptime facts are emitted -- **THEN** `system_uptime.days`, `system_uptime.hours`, and `system_uptime.seconds` MUST use 64-bit integer values -- **AND** those fields MUST match the numeric value types emitted by other supported platforms - -#### Scenario: Snapshot accessors clone public mutable values -- **WHEN** a Snapshot contains a mutable public fact value, including maps, slices, pointers, arrays, and exported struct fields -- **THEN** Snapshot construction, value lookup, and copy-returning accessors MUST clone mutable values and pointed-to values in that public graph -- **AND** mutating the source value or a returned value MUST NOT mutate the Snapshot -- **AND** maps with pointer-bearing keys MUST NOT expose original key pointers when a copied key remains valid for the map key type -- **AND** cyclic pointer, map, and slice values MUST preserve cycles inside the copied graph without linking back to the original graph -- **AND** distinct slices that share backing storage MUST remain distinct copies when their visible lengths differ -- **AND** unexported struct fields MAY be preserved by shallow value copy and are not part of the deep-clone guarantee diff --git a/openspec/specs/facts-cli-option-contract/spec.md b/openspec/specs/facts-cli-option-contract/spec.md index caf65605..8f6b1eba 100644 --- a/openspec/specs/facts-cli-option-contract/spec.md +++ b/openspec/specs/facts-cli-option-contract/spec.md @@ -120,3 +120,25 @@ Validation-time option errors SHALL render on stderr with the `ERROR Facts::Opti - **WHEN** the binary runs with an option combination rejected after config parsing (such as `--no-external-facts --external-dir DIR`) - **THEN** the error line MUST render without the OptionsValidator prefix, byte-identical to today's output, and the process MUST exit 1 + +### Requirement: CLI discovery options feed the shared plan +The `facts` CLI SHALL translate runtime flags and config values that affect discovery inputs, cache policy, and dotted query projection into engine discovery configuration instead of independently applying those discovery rules after `Discover`. + +#### Scenario: External source options are planned once +- **WHEN** the CLI runs with `--external-dir`, `--no-external-facts`, `--config`, or config-derived external dirs/blocklists +- **THEN** `internal/app` passes the resolved discovery policy into the engine path +- **AND** it does not duplicate source precedence, default-dir, or blocklist application outside the shared plan + +#### Scenario: Cache options are planned once +- **WHEN** the CLI runs with cache enabled, `--no-cache`, or config-derived cache TTL/groups +- **THEN** cache enablement and TTL/group policy are applied through the shared discovery plan +- **AND** persistent cache storage remains handled by the engine cache implementation + +#### Scenario: Force-dot resolution is projection-only +- **WHEN** the CLI runs with `--force-dot-resolution` or config `force-dot-resolution: true` +- **THEN** the value affects selected-query and formatter projection for dotted external or registered facts +- **AND** it does not affect source loading, source precedence, or the canonical Snapshot tree + +#### Scenario: CLI process-edge behavior stays in app +- **WHEN** discovery planning moves into the engine path +- **THEN** `internal/app` still owns stdout/stderr, help/man/version tasks, formatter selection, timing output, diagnostic rendering, strict exit behavior, and supported option validation diff --git a/openspec/specs/facts-documentation-site/spec.md b/openspec/specs/facts-documentation-site/spec.md new file mode 100644 index 00000000..2203bdf7 --- /dev/null +++ b/openspec/specs/facts-documentation-site/spec.md @@ -0,0 +1,72 @@ +# facts-documentation-site Specification + +## Purpose +TBD - created by archiving change add-hugo-github-pages-site. Update Purpose after archive. +## Requirements +### Requirement: Hugo documentation site +Facts SHALL provide a Hugo-based static documentation site whose source is committed to the repository and whose generated HTML is not committed. + +#### Scenario: Local Hugo build +- **WHEN** a contributor runs the documented Hugo build command from the repository root +- **THEN** Hugo MUST generate the site into the configured output directory +- **AND** the generated output directory MUST remain ignored or otherwise uncommitted + +#### Scenario: No JavaScript package pipeline +- **WHEN** a contributor inspects the site build inputs +- **THEN** the site MUST NOT require npm, a JavaScript bundler, Hugo modules, or a third-party Hugo theme + +### Requirement: Homepage presents Facts +The documentation site SHALL include a homepage that presents Facts as both an embeddable Go library and the `facts` CLI. + +#### Scenario: Homepage primary message +- **WHEN** a visitor opens `https://facts.martinez.io/` +- **THEN** the first screen MUST identify the product as Facts +- **AND** it MUST describe Facts as a Go port of Puppet Facter with both library and CLI usage + +#### Scenario: Homepage install actions +- **WHEN** a visitor scans the homepage +- **THEN** it MUST expose the Go library install command `go get github.com/ncode/facts` +- **AND** it MUST expose the CLI install command `brew install ncode/tap/facts` + +### Requirement: Existing docs are rendered as site content +The documentation site SHALL render the existing Markdown documentation under `docs/` as the source content for documentation pages. + +#### Scenario: Docs navigation +- **WHEN** a visitor uses the site navigation +- **THEN** it MUST expose shallow groups for Start, Library, CLI, Supported facts, and Project +- **AND** those groups MUST link to the relevant existing documentation pages + +#### Scenario: Supported fact pages remain generated +- **WHEN** supported fact reference pages are displayed on the site +- **THEN** they MUST be rendered from `docs/supported-facts/*.md` +- **AND** their content MUST remain owned by the existing schema-driven generation flow + +### Requirement: README-derived visual system +The documentation site SHALL use the visual language already established in the README assets. + +#### Scenario: Palette and typography +- **WHEN** the site is rendered +- **THEN** it MUST use the README palette: near-black ink, near-white canvas surfaces, muted gray text, hairline borders, and the existing blue/cyan/violet/pink/coral/amber accent colors +- **AND** it MUST use system sans-serif and monospace font stacks without remote font dependencies + +#### Scenario: Hero visual treatment +- **WHEN** the homepage first screen is rendered +- **THEN** it MUST include a hero-scale mesh-gradient treatment based on the README hero colors +- **AND** the gradient MUST be used as a large atmospheric element, not as small decorative swatches + +### Requirement: GitHub Pages custom-domain deployment +Facts SHALL deploy the generated documentation site to GitHub Pages at `https://facts.martinez.io/`. + +#### Scenario: Pages artifact deployment +- **WHEN** changes are merged to the default branch +- **THEN** a GitHub Actions workflow MUST build the Hugo site +- **AND** it MUST publish the generated site using GitHub Pages artifact deployment + +#### Scenario: Custom domain file +- **WHEN** the site is built for GitHub Pages +- **THEN** the generated artifact MUST include a `CNAME` file containing `facts.martinez.io` + +#### Scenario: Canonical base URL +- **WHEN** Hugo renders absolute URLs +- **THEN** the configured base URL MUST be `https://facts.martinez.io/` + diff --git a/openspec/specs/facts-library-api/spec.md b/openspec/specs/facts-library-api/spec.md index 8334def7..c5a7e8b9 100644 --- a/openspec/specs/facts-library-api/spec.md +++ b/openspec/specs/facts-library-api/spec.md @@ -34,7 +34,7 @@ An Engine SHALL be immutable after construction, with all fact registrations and - **THEN** each Snapshot reflects only its own Engine's configuration, with no cross-engine interference and no data races ### Requirement: Canonical tree queries and generic decode -A Snapshot SHALL expose the canonical tree — the same fact names, nesting, and value normalization the output contract pins — through pure query operations using Facter dot-notation, and a generic decode (`facts.As[T]`) SHALL convert any queried subtree into a caller-supplied type. Decode MUST read from the resolved canonical tree and MUST NOT resolve facts independently. Snapshot value lookup SHALL use the same internal projection semantics as CLI query projection where their contracts overlap, while preserving the library distinction between missing facts and resolved nil registered/external facts. +A Snapshot SHALL expose the canonical tree — the same fact names, nesting, and value normalization the output contract pins — through pure query operations using Facter dot-notation, and a generic decode (`facts.As[T]`) SHALL convert any queried subtree into a caller-supplied type. Decode MUST read from the resolved canonical tree and MUST NOT resolve facts independently. Snapshot value lookup SHALL use the same internal projection semantics as CLI query projection where their contracts overlap, while preserving the library distinction between missing facts and resolved nil registered/external facts. Internal presentation consumers MUST receive a defensive Projection rather than raw Snapshot records, and that Projection MUST NOT expand the public Snapshot API or permit mutation of Snapshot state. #### Scenario: Dotted query resolution - **WHEN** a consumer queries `snapshot.Value("os.release.major")` @@ -48,6 +48,26 @@ A Snapshot SHALL expose the canonical tree — the same fact names, nesting, and - **WHEN** an operator-supplied fact has reshaped a name (e.g. an external fact redefines `os` as a string) and a consumer decodes it into an incompatible type - **THEN** `facts.As[T]` returns a non-nil error describing the mismatch and never returns a partially or silently coerced value +#### Scenario: Presentation cannot mutate a Snapshot +- **WHEN** the internal CLI adapter formats a Snapshot through a presentation Projection and formatter or custom-value code mutates a returned map, slice, pointer, array, or exported struct field +- **THEN** subsequent `Snapshot.Value`, `Snapshot.Tree`, `Snapshot.All`, and `facts.As[T]` calls MUST observe the original immutable Snapshot values + +#### Scenario: Internal presentation boundary hides resolved records +- **WHEN** `internal/app` obtains a Snapshot's defensive presentation Projection +- **THEN** no Snapshot method or app-visible Projection selection or iterator operation SHALL expose its backing `[]ResolvedFact` records +- **AND** normal app and formatter paths MUST consume only Projection shape, value, name, and missing-query views +- **AND** the version-query fast path MAY construct its separate synthetic resolved fact solely to build its independent presentation Projection + +#### Scenario: Force-dot presentation does not replace the canonical tree +- **WHEN** the CLI requests force-dot presentation for dotted external or registered facts +- **THEN** the internal presentation Projection MAY merge those dotted facts for query/output behavior +- **AND** the Snapshot's canonical tree and public query/decode results MUST retain the existing non-force-dot semantics + +#### Scenario: Public Snapshot surface remains unchanged +- **WHEN** the internal raw-fact formatter escape is replaced by a presentation Projection +- **THEN** the public Snapshot SHALL continue to expose only its existing canonical tree, value lookup, ordered iteration, and generic decode operations +- **AND** no public raw resolved-fact or Projection accessor SHALL be added + ### Requirement: Error semantics The library SHALL distinguish missing facts from nil-valued facts via an `ErrFactNotFound` sentinel, SHALL return partial results with aggregated errors on partial discovery failure, and SHALL NOT treat not-applicable facts as failures. @@ -97,3 +117,25 @@ Engine diagnostics SHALL flow through `log/slog` with the contract-pinned messag - **WHEN** an Engine constructed with `WithLogger` raises an error-class diagnostic (a collection collision, an unsupported cache group for an external fact, or an unparseable TTL unit) - **THEN** the diagnostic is emitted to the supplied logger at error severity, even though the facts CLI's stderr handler drops error-class lines +### Requirement: Discovery uses one input plan per run +The library SHALL derive source loading, blocklist, cache, and query-selection policy from one internal discovery plan for each `Discover` call. The plan MUST be recomputed per discovery so config files, external fact directories, environment facts, executable facts, and cache contents remain fresh across repeated discovery on the same immutable Engine. + +#### Scenario: Config is read at discovery time +- **WHEN** an Engine configured with `WithConfigFile` discovers facts, the config file changes, and the same Engine discovers facts again +- **THEN** the second Snapshot reflects the updated config-derived external dirs, blocklists, and cache TTL/group policy + +#### Scenario: Query selection happens in discovery +- **WHEN** a consumer calls `Discover(ctx, "os.family")` +- **THEN** the returned Snapshot is backed by facts selected with the same projection semantics used by CLI query projection where the contracts overlap +- **AND** the public `Discover(ctx, queries...)` method shape remains unchanged + +#### Scenario: Cache policy stays discovery-scoped +- **WHEN** an Engine is configured with cache enabled and config-derived TTL/group policy +- **THEN** discovery applies cache resolution and cache refresh according to the per-discovery plan +- **AND** the Engine does not memoize resolved fact values between discoveries + +#### Scenario: Force-dot resolution is not public library configuration +- **WHEN** a library consumer constructs an Engine through public `facts` options +- **THEN** no public option exists for force-dot resolution +- **AND** the canonical Snapshot tree preserves existing dotted external and registered fact behavior + diff --git a/openspec/specs/facts-native-input-surface/spec.md b/openspec/specs/facts-native-input-surface/spec.md index 27c783e2..f2f50765 100644 --- a/openspec/specs/facts-native-input-surface/spec.md +++ b/openspec/specs/facts-native-input-surface/spec.md @@ -47,3 +47,19 @@ Facts SHALL treat an environment variable whose resolved fact name is `disable` - **WHEN** any of `FACTS_DISABLE`, `FACTSDISABLE`, `FACTER_DISABLE`, or `FACTERDISABLE` is set - **THEN** it MUST be treated as the disable control - **AND** it MUST NOT create a `disable` fact + +### Requirement: Input source planning is shared +Facts SHALL plan facts-native and facter-compatible input sources through one discovery-time module for CLI-equivalent and library system-following discovery. The plan MUST preserve native-name precedence, configured external dirs, default external dirs, environment fact precedence, `no-external-facts`, and blocklist semantics. + +#### Scenario: Library system defaults use the shared input plan +- **WHEN** an Engine constructed with `WithSystemDefaults` discovers facts on a host with native and facter-compatible config/input sources +- **THEN** discovery applies the same native-before-compatible input precedence as the `facts` CLI + +#### Scenario: Explicit library dirs remain explicit +- **WHEN** an Engine constructed with `WithExternalDirs` discovers facts +- **THEN** the shared input plan loads exactly the supplied external dirs and does not add default external dirs or environment facts + +#### Scenario: No external facts suppresses all external inputs +- **WHEN** discovery policy has `no-external-facts` enabled from CLI or config +- **THEN** the input plan omits external fact directories and external environment facts +- **AND** core and registered facts can still resolve diff --git a/openspec/specs/facts-schema/spec.md b/openspec/specs/facts-schema/spec.md index daccf2d4..d6d994dd 100644 --- a/openspec/specs/facts-schema/spec.md +++ b/openspec/specs/facts-schema/spec.md @@ -240,3 +240,58 @@ Facts SHALL require emitted fact leaves under dynamic keyed maps to match docume - **WHEN** discovery emits provider-shaped metadata under a schema entry explicitly marked as an open subtree - **THEN** schema conformance MAY accept arbitrary descendants under that subtree +### Requirement: Installed packages are reported as source-namespaced record lists + +Facts SHALL document the `packages` fact as a map from package-source key to an array of package records, where each record is one installed package identified by its fields, and records are never merged across sources. + +#### Scenario: packages schema is source-namespaced arrays + +- **WHEN** a contributor reads `docs/schema/facts.yaml` +- **THEN** `packages` MUST be documented as a map keyed by source +- **AND** each `packages.` MUST have type `array` +- **AND** each array element MUST be a package record (a map), never a bare scalar +- **AND** the documented source keys MUST be `dpkg`, `rpm`, `pacman`, `apk`, `snap`, `flatpak`, `pkg`, `openbsd_pkg`, `pkgsrc`, `ips`, `nix`, `receipts`, `apps`, `homebrew`, `registry`, and `appx` + +#### Scenario: package records carry name and version + +- **WHEN** a package record is documented +- **THEN** it MUST include `name` and `version` as always-present strings +- **AND** `version` MUST be the package manager's verbatim native version string, not decomposed into separate epoch or release fields + +#### Scenario: per-source identity fields are documented + +- **WHEN** a source needs more than `name` and `version` to keep two installs distinct +- **THEN** the schema MUST document its identity fields: `architecture` for `dpkg`/`rpm`/`apk`, `type` and `tap` for `homebrew`, `branch` and `architecture` for `flatpak`, `store_path` for `nix`, `bundle_id` and `path` for `apps`, and `product_code` and `architecture` for `registry` + +### Requirement: Package sources are keyed by the installed-package database + +Facts SHALL key each package source by its installed-package database, never by a package-manager frontend or an artifact file format. + +#### Scenario: frontends collapse to their database + +- **WHEN** packages are read on a host managed with apt/aptitude or dnf/yum/zypper +- **THEN** they MUST be reported under `packages.dpkg` or `packages.rpm` respectively +- **AND** `packages.apt`, `packages.deb`, `packages.dnf`, `packages.yum`, and `packages.zypper` MUST NOT be documented as sources + +#### Scenario: IPS is keyed ips, not pkg + +- **WHEN** packages are read on illumos or Solaris +- **THEN** they MUST be reported under `packages.ips` +- **AND** `packages.pkg` MUST refer only to the FreeBSD and DragonFly pkgng database + +### Requirement: Package records preserve every installed instance + +Facts SHALL keep one record per installed package instance so that same-named installs are never collapsed. + +#### Scenario: colliding installs are all kept + +- **WHEN** a host has two installs sharing a name (dpkg multiarch, rpm multiversion kernels, homebrew formula and cask, flatpak branches, Windows x86 and x64) +- **THEN** `packages.` MUST contain a distinct record for each +- **AND** the records MUST differ in at least one per-source identity field + +#### Scenario: absent sources are omitted and never merged + +- **WHEN** a package source's database is not present on the host +- **THEN** its `packages.` key MUST be absent, not an empty array +- **AND** records from different sources MUST NOT be merged or deduplicated into one list + diff --git a/openspec/specs/go-port-framework-parity/spec.md b/openspec/specs/go-port-framework-parity/spec.md index 51ae5a3d..9e4f5a4a 100644 --- a/openspec/specs/go-port-framework-parity/spec.md +++ b/openspec/specs/go-port-framework-parity/spec.md @@ -47,7 +47,7 @@ Facts SHALL NOT expose legacy alias facts in any output mode. The canonical stru - **THEN** the config MUST load without error and discovery output MUST be identical to a run without the entry ### Requirement: Config, cache, query, formatter, and logging parity -The Go port SHALL preserve Ruby-compatible framework behavior around configuration, cache groups, fact filtering, query selection, formatting, and diagnostics. Selected-query projection, dotted fact mode, selected-query values, nil rendering, and strict missing-fact detection SHALL be centralized behind one internal projection module so CLI strict mode and formatters do not rebuild those rules independently. +The Go port SHALL preserve Ruby-compatible framework behavior around configuration, cache groups, fact filtering, query selection, formatting, and diagnostics. Selected-query projection, dotted fact mode, selected-query values, nil rendering, presentation names, and strict missing-fact detection SHALL be centralized behind one internal projection module. Normal CLI formatter, timing-name, and strict-mode paths MUST consume one presentation Projection rather than receiving raw resolved-fact records and rebuilding those rules independently. #### Scenario: Configuration and cache behavior - **WHEN** Facter reads config files, fact groups, TTLs, blocklists, default paths, invalid config, unreadable config, cache files, expired cache, corrupt cache, and external fact cache groups @@ -57,6 +57,34 @@ The Go port SHALL preserve Ruby-compatible framework behavior around configurati - **WHEN** Facter formats selected or unselected facts as legacy text, JSON, YAML, or HOCON - **THEN** the Go port MUST match Ruby behavior for nested fact selection, arrays, dotted fact names, nil rendering, scalar formatting, map ordering where specified, string quoting, IPv6/path handling, and collision diagnostics - **AND** selected-query projection, dotted fact mode, selected-query value maps, and strict missing-query detection MUST be provided by the internal projection module rather than duplicated across CLI and formatter paths +- **AND** formatter adapters MUST consume shape and selected values from a presentation Projection rather than reconstructing a Projection from raw Snapshot records +- **AND** each formatter MAY retain the format-specific scalar, map, ordering, quoting, and legacy transformation rules required for byte compatibility + +#### Scenario: Normal CLI presentation reuses one projection +- **WHEN** the CLI completes normal discovery and then formats output, emits timing names, or classifies missing queries for strict mode +- **THEN** those paths MUST consume one defensive presentation Projection configured with the effective dotted-fact mode +- **AND** timing rendering, output routing, strict diagnostics, and strict exit status MUST remain owned by the CLI adapter + +#### Scenario: Strict missing-query classification keeps CLI semantics +- **WHEN** the presentation Projection classifies selected records for strict mode +- **THEN** a selected registered or external fact resolved to nil MUST be reported missing even though public Snapshot lookup treats that resolved nil as found +- **AND** missing query names MUST retain selection order and duplicates +- **AND** full-output records with an empty `UserQuery` MUST NOT be reported missing + +#### Scenario: Presentation names retain record order +- **WHEN** timing output asks the presentation Projection for display names +- **THEN** it MUST return one name for every selected record in original order, including duplicate queries +- **AND** each name MUST use `UserQuery` when non-empty and otherwise fall back to the resolved fact `Name` + +#### Scenario: Presentation shape retains formatter semantics +- **WHEN** a formatter receives an empty selection, full-output records, one distinct selected query, repeated copies of one query, or multiple distinct queries +- **THEN** Projection MUST classify them respectively as empty, full-tree, scalar, scalar, or query-map output +- **AND** every formatter MUST preserve its existing empty-output, scalar, nil, and container bytes + +#### Scenario: Projection lifetimes remain distinct +- **WHEN** discovery selects queries, a Snapshot serves canonical lookup, the CLI applies force-dot presentation, or the version-query fast path renders its synthetic fact +- **THEN** each path MUST use a Projection with the lifetime and dotted-fact mode required by that path +- **AND** the implementation MUST NOT reuse a force-dot presentation tree as the canonical Snapshot tree #### Scenario: Logging and diagnostics - **WHEN** framework code emits debug, info, warning, error, once-only, exception, HTTP debug, timing, strict missing-fact, parser, resolver, cache, or external fact diagnostics diff --git a/openspec/specs/go-port-supported-platform-facts/spec.md b/openspec/specs/go-port-supported-platform-facts/spec.md index 84bb0853..529effae 100644 --- a/openspec/specs/go-port-supported-platform-facts/spec.md +++ b/openspec/specs/go-port-supported-platform-facts/spec.md @@ -24,7 +24,8 @@ The Go port SHALL treat Linux, macOS/Darwin, Windows, FreeBSD, OpenBSD, NetBSD, - **AND** Oracle Solaris MUST remain outside the supported release target set until it has its own repeatable validation host ### Requirement: Core fact parity -The Go port SHALL expose Ruby-compatible structured facts for each supported platform where Ruby Facter has comparable behavior, except for the intentionally removed Ruby runtime and Puppet package-version built-ins. Legacy alias facts are not part of the surface. Facts MAY expose Facts-native extensions when the native source is stable, the canonical fact spelling is schema-documented, and platform validation covers the behavior. + +The Go port SHALL expose Ruby-compatible structured facts for each supported platform where Ruby Facter has comparable behavior, except for the intentionally removed Ruby runtime and Puppet package-version built-ins. Legacy alias facts are not part of the surface. Facts MAY expose Facts-native extensions when the native source is stable, the canonical fact spelling is schema-documented, and platform validation covers the behavior. Linux DHCP lease attribution for interface-level networking facts MUST use the exact interface and lease-block semantics specified below. #### Scenario: Linux fact parity - **WHEN** Linux facts are resolved for OS/release/distro, SELinux, identity, networking, DHCP, memory, swap, processors, DMI, disks, partitions, filesystems, mountpoints, uptime, load averages, virtualization, hypervisors, cloud metadata, SSH, timezone, path, FIPS, and Augeas @@ -61,6 +62,42 @@ The Go port SHALL expose Ruby-compatible structured facts for each supported pla - **AND** `os.family` MUST be `illumos`, `kernel.name` MUST be `SunOS`, and `os.name` MUST report the validated distribution such as `OmniOS` - **AND** Oracle Solaris-specific behavior MUST remain absent until the Oracle Solaris target is separately validated +#### Scenario: Linux DHCP lease interface declarations match exactly +- **WHEN** Linux DHCP lease files are scanned for `networking.interfaces..dhcp` +- **AND** a lease file contains one or more explicit dhclient `interface "..."` declarations +- **THEN** Facts MUST use that lease only when one non-comment declaration exactly equals the requested interface name +- **AND** when a file contains multiple lease blocks, Facts MUST extract the DHCP server from the matching interface block rather than from a later block for another interface +- **AND** Facts MUST parse lease block boundaries without treating braces inside comments or quoted strings as lease terminators +- **AND** if a malformed lease block has no terminator or contains an unterminated quoted string, Facts MUST continue scanning later valid lease blocks rather than falling back to a whole-file DHCP server from another interface +- **AND** malformed interface quoted values MUST NOT count as explicit interface declarations or suppress lease filename fallback +- **AND** Facts MUST recognize explicit interface declarations even when dhclient writes multiple statements on one line +- **AND** when an exact interface declaration appears outside the lease block, Facts MUST still use the file-level DHCP server identifier for that interface +- **AND** when a file-level interface declaration matches and lease blocks omit per-block interface declarations, Facts MUST use the latest DHCP server identifier from those historical leases +- **AND** when multiple lease blocks exactly match the requested interface, the latest matching block MUST control the DHCP server value even if it omits `dhcp-server-identifier` +- **AND** commented or quoted `dhcp-server-identifier` text MUST NOT count as a DHCP server option +- **AND** explicit lease blocks for other interfaces MUST NOT override a file-level declaration for the requested interface +- **AND** Facts MUST NOT treat interface names that merely contain the requested name, such as `eth0-backup` for `eth0`, as a match +- **AND** lease filename fallback MAY still apply when a lease file has no explicit interface declaration + +#### Scenario: YAML sequence map values preserve all keys +- **WHEN** YAML output renders a sequence item whose value is a map with multiple keys +- **THEN** Facts MUST render that map as valid YAML that preserves every key/value pair in the sequence item +- **AND** the sequence item MUST NOT collapse the map into a scalar value for the first key + +#### Scenario: Plan 9 uptime duration fields use shared numeric types +- **WHEN** Plan 9 uptime facts are emitted +- **THEN** `system_uptime.days`, `system_uptime.hours`, and `system_uptime.seconds` MUST use 64-bit integer values +- **AND** those fields MUST match the numeric value types emitted by other supported platforms + +#### Scenario: Snapshot accessors clone public mutable values +- **WHEN** a Snapshot contains a mutable public fact value, including maps, slices, pointers, arrays, and exported struct fields +- **THEN** Snapshot construction, value lookup, and copy-returning accessors MUST clone mutable values and pointed-to values in that public graph +- **AND** mutating the source value or a returned value MUST NOT mutate the Snapshot +- **AND** maps with pointer-bearing keys MUST NOT expose original key pointers when a copied key remains valid for the map key type +- **AND** cyclic pointer, map, and slice values MUST preserve cycles inside the copied graph without linking back to the original graph +- **AND** distinct slices that share backing storage MUST remain distinct copies when their visible lengths differ +- **AND** unexported struct fields MAY be preserved by shallow value copy and are not part of the deep-clone guarantee + ### Requirement: Resolver fallback and diagnostic parity The Go port SHALL preserve supported-platform Ruby resolver fallback order and diagnostics, and SHALL reach all host command execution and file reads through the resolution Session's host seam so that every platform resolver is exercisable with an injected fake host — no resolver reads files or runs commands outside an injectable seam. @@ -397,3 +434,112 @@ The host-virtualization signal gather (on Linux: DMI reads plus the dmidecode/vi - **WHEN** the memoized gather input is classified for the `virtual` fact and for the `hypervisors` tree - **THEN** each consumer's classification produces the same fact names and values as before memoization, including their documented divergences +### Requirement: DragonFly disk probes drop empty memory disks without dropping real storage + +Facts SHALL exclude DragonFly memory-disk and optical pseudo-devices (`md`, `cd`, matched by driver class — the device name with trailing digits stripped) from the DragonFly `disks` and `partitions` facts, while keeping real disks, their real slices, and attached file-backed (`vn`) disks. + +#### Scenario: empty memory disks are neither probed nor reported + +- **WHEN** `kern.disks` lists empty `md` memory disks alongside a real disk +- **THEN** the probe MUST NOT spawn a `disklabel` subprocess for the `md` devices +- **AND** `disks` and `partitions` MUST NOT report them + +#### Scenario: real disks and attached file-backed disks are unchanged + +- **WHEN** a real disk (`da0`) or an attached file-backed disk (a configured `vn0`) is present +- **THEN** its `disks` and `partitions` facts MUST be identical to before this change + +### Requirement: The DragonFly partition probe enumerates only existing slices + +Facts SHALL probe only the slices that actually exist for a DragonFly device — discovered by enumerating `/dev/s` slice nodes — rather than a fixed `s1`–`s4` set plus the whole disk, so discovery does not spawn wasted `disklabel` processes on hosts with many devices. + +#### Scenario: no fan-out to non-existent slices + +- **WHEN** the DragonFly probe enumerates a device's partitions +- **THEN** it MUST issue `disklabel` only for slice targets that exist on the device +- **AND** it MUST NOT issue a fixed `device + s1..s4` set of `disklabel` calls per device +- **AND** it MUST NOT issue `disklabel` on the whole-disk device (the DragonFly label lives on the slice) + +### Requirement: Supported targets report their native installed-package sources + +Facts SHALL resolve the `packages` fact on each supported release target using the package source(s) native to that target. + +#### Scenario: Linux targets report their package database and cross-distro sources + +- **WHEN** packages are discovered on a supported Linux target +- **THEN** Debian and Ubuntu MUST report `packages.dpkg` +- **AND** the RHEL family and SUSE MUST report `packages.rpm` +- **AND** Arch MUST report `packages.pacman` and Alpine MUST report `packages.apk` +- **AND** `packages.snap` and `packages.flatpak` MUST be reported wherever those system databases are present +- **AND** `packages.nix` MUST be reported wherever a Nix profile is present + +#### Scenario: BSD and illumos targets report their package database + +- **WHEN** packages are discovered on a supported BSD or illumos target +- **THEN** FreeBSD and DragonFly MUST report `packages.pkg` +- **AND** OpenBSD MUST report `packages.openbsd_pkg` +- **AND** NetBSD MUST report `packages.pkgsrc` +- **AND** illumos MUST report `packages.ips` + +#### Scenario: macOS reports receipts, apps, and optional homebrew + +- **WHEN** packages are discovered on macOS +- **THEN** `packages.receipts` MUST be reported from the installer receipt database +- **AND** `packages.apps` MUST be reported from the installed `.app` inventory and MUST NOT be merged into `receipts` +- **AND** `packages.homebrew` MUST be reported only when a Homebrew prefix exists + +#### Scenario: Windows reports registry and appx + +- **WHEN** packages are discovered on Windows +- **THEN** `packages.registry` MUST be reported from both HKLM uninstall hives +- **AND** `packages.appx` MUST be reported from the provisioned set plus the collector context + +#### Scenario: Plan 9 reports no packages + +- **WHEN** packages are discovered on Plan 9 +- **THEN** no `packages` subtree MUST be emitted + +### Requirement: Package collection stays cheap and context-bounded + +Facts SHALL collect each package source with a single cheap read and SHALL report only system-global databases plus the collector's own execution context. + +#### Scenario: no per-package process spawning or network + +- **WHEN** a package source is collected +- **THEN** it MUST be read with one on-disk database read or one batch query +- **AND** it MUST NOT spawn one process per package +- **AND** it MUST NOT perform any network request + +#### Scenario: other users' per-user installs are not enumerated + +- **WHEN** a host has per-user installs owned by other users (homebrew, nix profiles, Windows HKCU, per-user flatpak or appx) +- **THEN** discovery MUST NOT drop privileges or walk other users' homes to enumerate them +- **AND** the under-report MUST be documented + +### Requirement: Core-fact test-surface cleanup preserves production coordination + +Removing obsolete internal test entrances SHALL NOT change core-fact match state, probe order, fallback behavior, or resolved output. Regression coverage for coordination decisions affected by this cleanup MUST observe production-owned category assembly or pure seams rather than a narrower parallel contract. + +#### Scenario: DHCP match state remains fully covered + +- **WHEN** DHCP lease-interface matching is tested through the production state-returning seam +- **THEN** coverage MUST include `(matched=false, explicit=false)`, `(matched=false, explicit=true)`, and `(matched=true, explicit=true)` +- **AND** the server value MUST be asserted for every relevant lease form, including a matched lease with an empty server value + +#### Scenario: Uptime source selection remains covered + +- **WHEN** uptime behavior is tested after the duration-only wrapper is removed +- **THEN** coverage MUST assert duration and `Known` through the production result +- **AND** fake-host call assertions MUST preserve the platform-specific probe/source-selection order + +#### Scenario: DragonFly DMI fallback remains lazy + +- **WHEN** DragonFly DMI coordination is tested after the convenience wrapper is removed +- **THEN** kenv data MUST short-circuit dmidecode through the production host seam +- **AND** dmidecode MUST remain a lazy fallback when kenv cannot supply the fact + +#### Scenario: Test-surface cleanup preserves supported facts + +- **WHEN** test-only entrances and dead utilities are removed +- **THEN** resolved fact names, values, platform gating, fallback order, and diagnostics MUST remain unchanged on every supported and candidate release target +