diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e22a168..b58c7fef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@
### Added
+- New `packages` fact: the installed packages on the host, namespaced by the
+ package database they come from — `packages.dpkg`, `packages.rpm`,
+ `packages.pacman`, `packages.apk`, `packages.snap`, `packages.flatpak`,
+ `packages.nix` (Linux); `packages.pkg` (FreeBSD/DragonFly),
+ `packages.openbsd_pkg`, `packages.pkgsrc`, `packages.ips`; `packages.receipts`,
+ `packages.apps`, `packages.homebrew` (macOS); and `packages.registry`,
+ `packages.appx` (Windows). Each source is an array of `{name, version, ...}`
+ records with per-source identity fields (architecture, product_code, bundle_id,
+ and so on); sources are never merged, and a source is omitted when its database
+ is absent. Scope is system package databases (language/runtime managers are out
+ of scope). The whole probe is skippable with `--disable packages`.
- Fact disabling is now a first-class, facts-native control. Disable any fact
or fact group with `--disable a,b`, the `FACTS_DISABLE=a,b` environment
variable, or the `facts.conf` `disable` key; the Facter `blocklist` key keeps
diff --git a/README.md b/README.md
index 06d330ee..b0f74cb7 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
-Facts is a Go port of [Puppet Facter](https://github.com/puppetlabs/facter). It discovers facts about the system it runs on — hardware, networking, OS, cloud metadata — and serves them two ways: as an embeddable Go library, and as the `facts` CLI. It reads your existing facter configuration, fact directories, and `FACTER_*` environment facts as the compatibility tier of its input surface, so facter-configured hosts keep working unchanged.
+Facts is a Go port of [Puppet Facter](https://github.com/puppetlabs/facter). It discovers facts about the system it runs on — hardware, networking, OS, installed packages, cloud metadata — and serves them two ways: as an embeddable Go library, and as the `facts` CLI. It reads your existing facter configuration, fact directories, and `FACTER_*` environment facts as the compatibility tier of its input surface, so facter-configured hosts keep working unchanged.
Ruby Facter compatibility is promised exactly where it matters and nowhere else: at the CLI process boundary (the output contract) and for operator-supplied fact sources — external facts and `facter.conf` (the input contract, now read under facts-native names first; see [the input-compatibility reference](docs/FACTER_CONF_COMPATIBILITY.md)). Ruby DSL fact files are not read; rewrite them as external facts ([migration guide](docs/CUSTOM_FACT_MIGRATION.md)). The Go API itself is just Go.
diff --git a/docs/schema/facts.yaml b/docs/schema/facts.yaml
index 76362b39..9c34598d 100644
--- a/docs/schema/facts.yaml
+++ b/docs/schema/facts.yaml
@@ -795,6 +795,87 @@ os.windows.system32:
description: The native system32 directory, sysnative-aware for 32-bit processes.
platforms: [windows]
+packages.apk:
+ type: array
+ description: Installed Alpine apk packages as {name, version, architecture} records, from /lib/apk/db/installed.
+ platforms: [linux]
+ conditional: true
+packages.appx:
+ type: array
+ description: Windows AppX/MSIX packages as {name, version, architecture} records — the system-provisioned set plus the collector context's packages, deduplicated.
+ platforms: [windows]
+ conditional: true
+packages.apps:
+ type: array
+ description: macOS application bundles as {name, version, bundle_id, path} records; secondary to receipts and never merged with it.
+ platforms: [darwin]
+ conditional: true
+packages.dpkg:
+ type: array
+ description: Installed dpkg packages as {name, version, architecture} records (installed-state entries including held and trigger states; multiarch siblings kept), from /var/lib/dpkg/status.
+ platforms: [linux]
+ conditional: true
+packages.flatpak:
+ type: array
+ description: Flatpak applications and runtimes from the system and collector-user installations as {name, version, architecture, branch} records; branch distinguishes same-version siblings.
+ platforms: [linux]
+ conditional: true
+packages.homebrew:
+ type: array
+ description: Homebrew formulae and casks as {name, version, type, prefix} records from every detected prefix (/opt/homebrew, /usr/local); prefix distinguishes dual-install duplicates.
+ platforms: [darwin]
+ conditional: true
+packages.ips:
+ type: array
+ description: Installed illumos IPS packages as {name, version} records, from the local image (no network refresh).
+ platforms: [illumos]
+ conditional: true
+packages.nix:
+ type: array
+ description: Installed Nix packages as {name, version} records — the NixOS system profile set, or the default profile (/nix/var/nix/profiles/default) on non-NixOS hosts; never the whole /nix/store.
+ platforms: [linux, darwin]
+ conditional: true
+packages.openbsd_pkg:
+ type: array
+ description: Installed OpenBSD packages as {name, version, architecture} records, from /var/db/pkg (architecture from each package's +CONTENTS @arch; omitted for arch-independent packages).
+ platforms: [openbsd]
+ conditional: true
+packages.pacman:
+ type: array
+ description: Installed pacman packages as {name, version, architecture} records, from /var/lib/pacman/local.
+ platforms: [linux]
+ conditional: true
+packages.pkg:
+ type: array
+ description: Installed pkgng packages as {name, version, architecture} records (shared by FreeBSD and DragonFly).
+ platforms: [freebsd, dragonfly]
+ conditional: true
+packages.pkgsrc:
+ type: array
+ description: Installed pkgsrc packages as {name, version} records, from the discovered PKG_DBDIR; NetBSD's primary source and an illumos/SmartOS and DragonFly secondary.
+ platforms: [netbsd, dragonfly, illumos]
+ conditional: true
+packages.receipts:
+ type: array
+ description: macOS installer(8)/PackageKit .pkg receipts as {name, version} records, from /var/db/receipts; the primary macOS source.
+ platforms: [darwin]
+ conditional: true
+packages.registry:
+ type: array
+ description: Windows uninstall entries from both HKLM hives as {name, version, product_code, architecture} records.
+ platforms: [windows]
+ conditional: true
+packages.rpm:
+ type: array
+ description: Installed rpm packages as {name, version, architecture} records (epoch preserved, gpg-pubkey filtered).
+ platforms: [linux]
+ conditional: true
+packages.snap:
+ type: array
+ description: Installed snap packages as {name, version} records.
+ platforms: [linux]
+ conditional: true
+
partitions.*:
type: map
description: A disk partition (or device-mapper/loop device), keyed by device path.
diff --git a/docs/supported-facts/README.md b/docs/supported-facts/README.md
index 201f855e..338de08b 100644
--- a/docs/supported-facts/README.md
+++ b/docs/supported-facts/README.md
@@ -6,12 +6,12 @@ These pages are generated from [`docs/schema/facts.yaml`](../schema/facts.yaml).
| Platform | Supported facts |
| --- | ---: |
-| [Linux](linux.md) | 175 |
-| [macOS / Darwin](darwin.md) | 107 |
-| [Windows](windows.md) | 101 |
-| [FreeBSD](freebsd.md) | 131 |
-| [OpenBSD](openbsd.md) | 113 |
-| [NetBSD](netbsd.md) | 117 |
-| [DragonFly BSD](dragonfly.md) | 115 |
-| [illumos](illumos.md) | 114 |
+| [Linux](linux.md) | 182 |
+| [macOS / Darwin](darwin.md) | 111 |
+| [Windows](windows.md) | 103 |
+| [FreeBSD](freebsd.md) | 132 |
+| [OpenBSD](openbsd.md) | 114 |
+| [NetBSD](netbsd.md) | 118 |
+| [DragonFly BSD](dragonfly.md) | 117 |
+| [illumos](illumos.md) | 116 |
| [Plan 9](plan9.md) | 29 |
diff --git a/docs/supported-facts/darwin.md b/docs/supported-facts/darwin.md
index 5fb4cd1c..f546ec9f 100644
--- a/docs/supported-facts/darwin.md
+++ b/docs/supported-facts/darwin.md
@@ -63,7 +63,7 @@ $ facts --json
## Fact Contract
-107 schema entries include `darwin`.
+111 schema entries include `darwin`.
| Fact | Type | Conditional | Description |
| --- | --- | --- | --- |
@@ -153,6 +153,10 @@ $ facts --json
| `os.release.full` | `string` | no | The full release number of the operating system. |
| `os.release.major` | `string` | no | The major release number of the operating system. |
| `os.release.minor` | `string` | yes | The minor release number of the operating system, when it has one. |
+| `packages.apps` | `array` | yes | macOS application bundles as {name, version, bundle_id, path} records; secondary to receipts and never merged with it. |
+| `packages.homebrew` | `array` | yes | Homebrew formulae and casks as {name, version, type, prefix} records from every detected prefix (/opt/homebrew, /usr/local); prefix distinguishes dual-install duplicates. |
+| `packages.nix` | `array` | yes | Installed Nix packages as {name, version} records — the NixOS system profile set, or the default profile (/nix/var/nix/profiles/default) on non-NixOS hosts; never the whole /nix/store. |
+| `packages.receipts` | `array` | yes | macOS installer(8)/PackageKit .pkg receipts as {name, version} records, from /var/db/receipts; the primary macOS source. |
| `path` | `array` | no | The PATH environment entries of the Facts process, in lookup order. |
| `processors.cores` | `integer` | no | The number of cores per processor socket. |
| `processors.count` | `integer` | yes | The number of logical processors. |
diff --git a/docs/supported-facts/dragonfly.md b/docs/supported-facts/dragonfly.md
index 372bed6b..6cdd85bb 100644
--- a/docs/supported-facts/dragonfly.md
+++ b/docs/supported-facts/dragonfly.md
@@ -67,7 +67,7 @@ $ facts --json
## Fact Contract
-115 schema entries include `dragonfly`.
+117 schema entries include `dragonfly`.
| Fact | Type | Conditional | Description |
| --- | --- | --- | --- |
@@ -161,6 +161,8 @@ $ facts --json
| `os.release.full` | `string` | no | The full release number of the operating system. |
| `os.release.major` | `string` | no | The major release number of the operating system. |
| `os.release.minor` | `string` | yes | The minor release number of the operating system, when it has one. |
+| `packages.pkg` | `array` | yes | Installed pkgng packages as {name, version, architecture} records (shared by FreeBSD and DragonFly). |
+| `packages.pkgsrc` | `array` | yes | Installed pkgsrc packages as {name, version} records, from the discovered PKG_DBDIR; NetBSD's primary source and an illumos/SmartOS and DragonFly secondary. |
| `partitions.*` | `map` | yes | A disk partition (or device-mapper/loop device), keyed by device path. |
| `partitions.*.filesystem` | `string` | yes | The filesystem type of the partition. |
| `partitions.*.mount` | `string` | yes | The path the partition is mounted on. |
diff --git a/docs/supported-facts/freebsd.md b/docs/supported-facts/freebsd.md
index 3e9f6942..04358d28 100644
--- a/docs/supported-facts/freebsd.md
+++ b/docs/supported-facts/freebsd.md
@@ -64,7 +64,7 @@ $ facts --json
## Fact Contract
-131 schema entries include `freebsd`.
+132 schema entries include `freebsd`.
| Fact | Type | Conditional | Description |
| --- | --- | --- | --- |
@@ -166,6 +166,7 @@ $ facts --json
| `os.release.major` | `string` | no | The major release number of the operating system. |
| `os.release.minor` | `string` | yes | The minor release number of the operating system, when it has one. |
| `os.release.patchlevel` | `string` | yes | The FreeBSD patch level of the installed userland. |
+| `packages.pkg` | `array` | yes | Installed pkgng packages as {name, version, architecture} records (shared by FreeBSD and DragonFly). |
| `partitions.*` | `map` | yes | A disk partition (or device-mapper/loop device), keyed by device path. |
| `partitions.*.filesystem` | `string` | yes | The filesystem type of the partition. |
| `partitions.*.mount` | `string` | yes | The path the partition is mounted on. |
diff --git a/docs/supported-facts/illumos.md b/docs/supported-facts/illumos.md
index 31879af5..6d1a70a1 100644
--- a/docs/supported-facts/illumos.md
+++ b/docs/supported-facts/illumos.md
@@ -89,7 +89,7 @@ $ facts --json
## Fact Contract
-114 schema entries include `illumos`.
+116 schema entries include `illumos`.
| Fact | Type | Conditional | Description |
| --- | --- | --- | --- |
@@ -178,6 +178,8 @@ $ facts --json
| `os.name` | `string` | no | The operating system name, such as Ubuntu, Darwin, windows, or Plan 9. |
| `os.release.full` | `string` | no | The full release number of the operating system. |
| `os.release.major` | `string` | no | The major release number of the operating system. |
+| `packages.ips` | `array` | yes | Installed illumos IPS packages as {name, version} records, from the local image (no network refresh). |
+| `packages.pkgsrc` | `array` | yes | Installed pkgsrc packages as {name, version} records, from the discovered PKG_DBDIR; NetBSD's primary source and an illumos/SmartOS and DragonFly secondary. |
| `partitions.*` | `map` | yes | A disk partition (or device-mapper/loop device), keyed by device path. |
| `partitions.*.filesystem` | `string` | yes | The filesystem type of the partition. |
| `partitions.*.size` | `string` | yes | The display size of the partition, such as 1.00 GiB. |
diff --git a/docs/supported-facts/linux.md b/docs/supported-facts/linux.md
index 0f9c4039..3d2e8dbb 100644
--- a/docs/supported-facts/linux.md
+++ b/docs/supported-facts/linux.md
@@ -70,7 +70,7 @@ $ facts --json
## Fact Contract
-175 schema entries include `linux`.
+182 schema entries include `linux`.
| Fact | Type | Conditional | Description |
| --- | --- | --- | --- |
@@ -217,6 +217,13 @@ $ facts --json
| `os.selinux.enabled` | `boolean` | no | Whether SELinux is enabled. |
| `os.selinux.enforced` | `boolean` | yes | Whether SELinux is enforcing, when SELinux is enabled. |
| `os.selinux.policy_version` | `string` | yes | The loaded SELinux policy version, when SELinux is enabled. |
+| `packages.apk` | `array` | yes | Installed Alpine apk packages as {name, version, architecture} records, from /lib/apk/db/installed. |
+| `packages.dpkg` | `array` | yes | Installed dpkg packages as {name, version, architecture} records (installed-state entries including held and trigger states; multiarch siblings kept), from /var/lib/dpkg/status. |
+| `packages.flatpak` | `array` | yes | Flatpak applications and runtimes from the system and collector-user installations as {name, version, architecture, branch} records; branch distinguishes same-version siblings. |
+| `packages.nix` | `array` | yes | Installed Nix packages as {name, version} records — the NixOS system profile set, or the default profile (/nix/var/nix/profiles/default) on non-NixOS hosts; never the whole /nix/store. |
+| `packages.pacman` | `array` | yes | Installed pacman packages as {name, version, architecture} records, from /var/lib/pacman/local. |
+| `packages.rpm` | `array` | yes | Installed rpm packages as {name, version, architecture} records (epoch preserved, gpg-pubkey filtered). |
+| `packages.snap` | `array` | yes | Installed snap packages as {name, version} records. |
| `partitions.*` | `map` | yes | A disk partition (or device-mapper/loop device), keyed by device path. |
| `partitions.*.backing_file` | `string` | yes | The file backing the loop device. |
| `partitions.*.filesystem` | `string` | yes | The filesystem type of the partition. |
diff --git a/docs/supported-facts/netbsd.md b/docs/supported-facts/netbsd.md
index 6467a773..bd2bbc85 100644
--- a/docs/supported-facts/netbsd.md
+++ b/docs/supported-facts/netbsd.md
@@ -85,7 +85,7 @@ $ facts --json
## Fact Contract
-117 schema entries include `netbsd`.
+118 schema entries include `netbsd`.
| Fact | Type | Conditional | Description |
| --- | --- | --- | --- |
@@ -175,6 +175,7 @@ $ facts --json
| `os.release.full` | `string` | no | The full release number of the operating system. |
| `os.release.major` | `string` | no | The major release number of the operating system. |
| `os.release.minor` | `string` | yes | The minor release number of the operating system, when it has one. |
+| `packages.pkgsrc` | `array` | yes | Installed pkgsrc packages as {name, version} records, from the discovered PKG_DBDIR; NetBSD's primary source and an illumos/SmartOS and DragonFly secondary. |
| `partitions.*` | `map` | yes | A disk partition (or device-mapper/loop device), keyed by device path. |
| `partitions.*.filesystem` | `string` | yes | The filesystem type of the partition. |
| `partitions.*.mount` | `string` | yes | The path the partition is mounted on. |
diff --git a/docs/supported-facts/openbsd.md b/docs/supported-facts/openbsd.md
index 6c288dcb..52328e77 100644
--- a/docs/supported-facts/openbsd.md
+++ b/docs/supported-facts/openbsd.md
@@ -64,7 +64,7 @@ $ facts --json
## Fact Contract
-113 schema entries include `openbsd`.
+114 schema entries include `openbsd`.
| Fact | Type | Conditional | Description |
| --- | --- | --- | --- |
@@ -156,6 +156,7 @@ $ facts --json
| `os.release.full` | `string` | no | The full release number of the operating system. |
| `os.release.major` | `string` | no | The major release number of the operating system. |
| `os.release.minor` | `string` | yes | The minor release number of the operating system, when it has one. |
+| `packages.openbsd_pkg` | `array` | yes | Installed OpenBSD packages as {name, version, architecture} records, from /var/db/pkg (architecture from each package's +CONTENTS @arch; omitted for arch-independent packages). |
| `partitions.*` | `map` | yes | A disk partition (or device-mapper/loop device), keyed by device path. |
| `partitions.*.filesystem` | `string` | yes | The filesystem type of the partition. |
| `partitions.*.mount` | `string` | yes | The path the partition is mounted on. |
diff --git a/docs/supported-facts/windows.md b/docs/supported-facts/windows.md
index d7740ef1..d0b78fb0 100644
--- a/docs/supported-facts/windows.md
+++ b/docs/supported-facts/windows.md
@@ -48,7 +48,7 @@ $ facts --json
## Fact Contract
-101 schema entries include `windows`.
+103 schema entries include `windows`.
| Fact | Type | Conditional | Description |
| --- | --- | --- | --- |
@@ -134,6 +134,8 @@ $ facts --json
| `os.windows.product_name` | `string` | no | The Windows product name, such as Windows Server 2022 Datacenter. |
| `os.windows.release_id` | `string` | no | The Windows release identifier (the display version when available). |
| `os.windows.system32` | `string` | no | The native system32 directory, sysnative-aware for 32-bit processes. |
+| `packages.appx` | `array` | yes | Windows AppX/MSIX packages as {name, version, architecture} records — the system-provisioned set plus the collector context's packages, deduplicated. |
+| `packages.registry` | `array` | yes | Windows uninstall entries from both HKLM hives as {name, version, product_code, architecture} records. |
| `path` | `array` | no | The PATH environment entries of the Facts process, in lookup order. |
| `processors.cores` | `integer` | no | The number of cores per processor socket. |
| `processors.count` | `integer` | yes | The number of logical processors. |
diff --git a/internal/engine/builtin_groups_test.go b/internal/engine/builtin_groups_test.go
index ea80edf5..648e0a04 100644
--- a/internal/engine/builtin_groups_test.go
+++ b/internal/engine/builtin_groups_test.go
@@ -19,6 +19,10 @@ func TestBuiltinFactGroups_keepStructuredRootsDropLegacyFlatNames(t *testing.T)
"operating system": "os",
"processor": "processors",
"path": "path",
+ // Facts-native group (ADR-0014): a deliberate parity divergence from
+ // Ruby Facter's group list, so `--list-block-groups` names packages as
+ // one disable unit.
+ "packages": "packages",
}
for name, root := range wantRoot {
facts, ok := groups[name]
diff --git a/internal/engine/core.go b/internal/engine/core.go
index 4ed57ebc..58d2249e 100644
--- a/internal/engine/core.go
+++ b/internal/engine/core.go
@@ -95,6 +95,7 @@ func buildCoreFacts(s *Session, disabled map[string]bool) []ResolvedFact {
gate("timezone", timezoneCoreFacts)
gate("augeas", augeasCoreFacts)
gate("xen", xenCoreFacts)
+ gate("packages", packagesCoreFacts)
facts = append(facts, currentLinuxHypervisorFacts(s)...)
facts = append(facts, currentWindowsHypervisorFacts(s)...)
facts = append(facts, azureFacts(s.Context(), newAzureClient(azureMetadataBaseURL, nil), virtualization)...)
diff --git a/internal/engine/core_gating_test.go b/internal/engine/core_gating_test.go
index 3109d0f1..4e6440a8 100644
--- a/internal/engine/core_gating_test.go
+++ b/internal/engine/core_gating_test.go
@@ -27,7 +27,7 @@ func hasRoot(facts []ResolvedFact, root string) bool {
// top-level fact name that gates it (ADR-0015).
var gatedSingleOutputCategories = []string{
"networking", "processors", "memory", "ssh",
- "timezone", "fips_enabled", "augeas", "xen",
+ "timezone", "fips_enabled", "augeas", "xen", "packages",
}
func TestBuildCoreFacts_resolutionGatesSingleOutputCategories(t *testing.T) {
@@ -41,7 +41,13 @@ func TestBuildCoreFacts_resolutionGatesSingleOutputCategories(t *testing.T) {
// assert gating for the ones that do produce output by default.
continue
}
- gated := buildCoreFacts(NewSession(), map[string]bool{fact: true})
+ // Each gated build also disables packages: its probe is the most
+ // expensive (plutil over every .app; PowerShell spawns on Windows
+ // runners), and gates are independent, so disabling it alongside does
+ // not affect the fact under test — it just keeps ~9 full package
+ // probes out of every CI run. The packages iteration itself still
+ // asserts its own gating.
+ gated := buildCoreFacts(NewSession(), map[string]bool{fact: true, "packages": true})
if hasRoot(gated, fact) {
t.Fatalf("buildCoreFacts(disabled=%q) still emitted %q root; resolver was not gated", fact, fact)
}
diff --git a/internal/engine/formatter.go b/internal/engine/formatter.go
index 334d95f6..f6730a7c 100644
--- a/internal/engine/formatter.go
+++ b/internal/engine/formatter.go
@@ -410,6 +410,52 @@ func isPlainYAMLString(value string) bool {
}
return false
}
+ return !yamlResolverRetypes(value)
+}
+
+// yamlResolverRetypes reports whether a YAML 1.1 resolver (Ruby Psych — the
+// Facter consumer stack — or PyYAML) parses the plain scalar as a non-string.
+// Package versions exercise every shape: "36" (Integer), "43_1" (Integer 431 —
+// underscores are YAML 1.1 digit separators), "0x1F"/"0755" (hex/octal), and
+// "2026-05-14" (Date, which even raises under Psych safe_load). Verified
+// against Ruby 3.4 Psych; such values must be quoted or consumers corrupt them.
+func yamlResolverRetypes(value string) bool {
+ digits := strings.TrimPrefix(strings.ReplaceAll(value, "_", ""), "-")
+ if digits != "" {
+ if _, err := strconv.ParseInt(digits, 0, 64); err == nil {
+ return true
+ }
+ allDigits := true
+ for _, r := range digits {
+ if r < '0' || r > '9' {
+ allDigits = false
+ break
+ }
+ }
+ if allDigits {
+ return true // digit strings beyond int64 still parse as Integer (bignum)
+ }
+ }
+ return yamlDateShaped(value)
+}
+
+// yamlDateShaped matches the YAML 1.1 date scalar (yyyy-mm-dd); datetime forms
+// contain ':' and are already forced into quotes by needsQuotedYAMLString.
+func yamlDateShaped(value string) bool {
+ parts := strings.Split(value, "-")
+ if len(parts) != 3 || len(parts[0]) != 4 || len(parts[1]) < 1 || len(parts[1]) > 2 || len(parts[2]) < 1 || len(parts[2]) > 2 {
+ return false
+ }
+ for _, part := range parts {
+ if part == "" {
+ return false
+ }
+ for _, r := range part {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ }
return true
}
diff --git a/internal/engine/formatter_test.go b/internal/engine/formatter_test.go
index cfffdded..052d6063 100644
--- a/internal/engine/formatter_test.go
+++ b/internal/engine/formatter_test.go
@@ -174,6 +174,21 @@ func TestIsPlainYAMLStringAcceptsOnlyRubySafeScalars(t *testing.T) {
{name: "yaml true prefix", value: "TrueNAS", want: false},
{name: "lowercase off", value: "off", want: false},
{name: "contains unsupported punctuation", value: "hello.world", want: false},
+ // Values Psych retypes (verified against Ruby 3.4 Psych): plain
+ // emission corrupts them, so they must be quoted. Package versions
+ // exercise every one of these shapes (apps "36", homebrew "43_1"
+ // revisions and "2026-05-14" ca-certificates dates).
+ {name: "integer-looking", value: "36", want: false},
+ {name: "underscore int separators", value: "43_1", want: false}, // Psych: 431
+ {name: "underscore thousands", value: "1_000_000", want: false},
+ {name: "hex int", value: "0x1F", want: false}, // Psych: 31
+ {name: "octal int", value: "0755", want: false}, // Psych: 493
+ {name: "date shape", value: "2026-05-14", want: false}, // Psych: Date; raises under safe_load
+ {name: "huge digit string", value: "12345678901234567890123456", want: false}, // Psych: bignum
+ // Shapes Psych keeps as String stay plain.
+ {name: "dash range", value: "8-10", want: true},
+ {name: "e-notation without dot", value: "1e3", want: true},
+ {name: "version with alpha prefix", value: "v1-2", want: true},
}
for _, tt := range tests {
diff --git a/internal/engine/groups.go b/internal/engine/groups.go
index dc469170..85d452e7 100644
--- a/internal/engine/groups.go
+++ b/internal/engine/groups.go
@@ -25,6 +25,10 @@ func BuiltinFactGroups() []FactGroup {
{Name: "memory", Facts: []string{"memory"}},
{Name: "networking", Facts: []string{"networking"}},
{Name: "operating system", Facts: []string{"os"}},
+ // Facts-native group (ADR-0014): a deliberate divergence from Ruby
+ // Facter's group list, so a disable names package collection as one
+ // unit and --list-block-groups shows it.
+ {Name: "packages", Facts: []string{"packages"}},
{Name: "path", Facts: []string{"path"}},
{Name: "processor", Facts: []string{"processors"}},
}
diff --git a/internal/engine/packages.go b/internal/engine/packages.go
new file mode 100644
index 00000000..e827f7c2
--- /dev/null
+++ b/internal/engine/packages.go
@@ -0,0 +1,263 @@
+package engine
+
+import (
+ "os"
+ "sort"
+ "strings"
+)
+
+// packagesCoreFacts resolves the packages fact: for each package database
+// present on the host, a source-namespaced array of {name, version, ...} records
+// (ADR-0014). Sources are never merged, and a source is omitted when its
+// database is absent or lists no installed packages. Registered as a
+// single-output resolver so a disable of "packages" gates the whole probe
+// (ADR-0015).
+func packagesCoreFacts(s *Session) []ResolvedFact {
+ sources := map[string]any{}
+ add := func(name string, records []any) {
+ if len(records) > 0 {
+ sources[name] = records
+ }
+ }
+
+ switch s.goos() {
+ case "linux":
+ add("dpkg", dpkgPackages(s.readFile))
+ if rpmDatabasePresent(s.stat) {
+ add("rpm", rpmPackages(s.commandOutput))
+ }
+ add("pacman", pacmanPackages(s.glob, s.readFile))
+ add("apk", apkPackages(s.readFile))
+ if snapdPresent(s.stat) {
+ add("snap", snapPackages(s.commandOutput))
+ }
+ if flatpakPresent(s.stat) {
+ add("flatpak", flatpakPackages(s.commandOutput))
+ }
+ if nixProfilePresent(s.stat) {
+ add("nix", nixPackages(s.commandOutput))
+ }
+ case "freebsd":
+ add("pkg", pkgngPackages(s.commandOutput))
+ case "dragonfly":
+ add("pkg", pkgngPackages(s.commandOutput))
+ add("pkgsrc", pkgsrcPackages(s.readDir)) // ADR-0014: pkgsrc secondary
+ case "openbsd":
+ add("openbsd_pkg", openbsdPackages(s.readDir, s.readFile))
+ case "netbsd":
+ add("pkgsrc", pkgsrcPackages(s.readDir))
+ case "illumos":
+ add("ips", ipsPackages(s.commandOutput))
+ add("pkgsrc", pkgsrcPackages(s.readDir)) // ADR-0014: SmartOS pkgsrc secondary
+ case "darwin":
+ add("receipts", receiptsPackages(s.glob, s.commandOutput))
+ add("apps", appsPackages(s.glob, s.commandOutput))
+ add("homebrew", homebrewPackages(s.glob))
+ if nixProfilePresent(s.stat) { // design: nix where a profile is present
+ add("nix", nixPackages(s.commandOutput))
+ }
+ case "windows":
+ add("registry", registryPackages(s.commandOutput))
+ add("appx", appxPackages(s.commandOutput))
+ }
+
+ if len(sources) == 0 {
+ return nil
+ }
+ return []ResolvedFact{{Name: "packages", Value: sources}}
+}
+
+// packageRecord builds a record with the always-present name/version plus any
+// non-empty identity fields supplied as key/value pairs.
+func packageRecord(name, version string, kv ...string) map[string]any {
+ record := map[string]any{"name": name, "version": version}
+ for i := 0; i+1 < len(kv); i += 2 {
+ if kv[i+1] != "" {
+ record[kv[i]] = kv[i+1]
+ }
+ }
+ return record
+}
+
+// sortPackages orders records deterministically by name, then architecture, then
+// version, so multiarch/multiversion siblings keep a stable order across runs.
+func sortPackages(records []any) {
+ sort.SliceStable(records, func(i, j int) bool {
+ a, b := records[i].(map[string]any), records[j].(map[string]any)
+ // name/architecture/version first, then the remaining identity fields,
+ // so siblings that share a name+arch+version still order deterministically
+ // regardless of the reader's append order.
+ for _, key := range []string{"name", "architecture", "version", "type", "branch", "prefix", "product_code", "bundle_id", "store_path", "path"} {
+ if av, bv := packageField(a, key), packageField(b, key); av != bv {
+ return av < bv
+ }
+ }
+ return false
+ })
+}
+
+func packageField(record map[string]any, key string) string {
+ value, _ := record[key].(string)
+ return value
+}
+
+// dpkgPackages parses /var/lib/dpkg/status, keeping only "install ok installed"
+// entries. Multiarch siblings (same name, different Architecture) are kept.
+func dpkgPackages(readFile fileReader) []any {
+ data, err := readFile("/var/lib/dpkg/status")
+ if err != nil {
+ return nil
+ }
+ var records []any
+ for _, stanza := range strings.Split(string(data), "\n\n") {
+ var name, version, arch, status string
+ for line := range strings.Lines(stanza) {
+ key, value, ok := strings.Cut(strings.TrimRight(line, "\n"), ": ")
+ if !ok {
+ continue
+ }
+ switch key {
+ case "Package":
+ name = value
+ case "Version":
+ version = value
+ case "Architecture":
+ arch = value
+ case "Status":
+ status = value
+ }
+ }
+ if name == "" || version == "" || !dpkgInstalled(status) {
+ continue
+ }
+ records = append(records, packageRecord(name, version, "architecture", arch))
+ }
+ sortPackages(records)
+ return records
+}
+
+// dpkgInstalled reports whether a dpkg Status line's current-state component
+// (the third field of " ok ") means the package is on disk and
+// functional: "installed", plus the two trigger states — a package awaiting or
+// pending trigger processing is fully unpacked and configured, and dpkg can sit
+// in those states across discoveries. Held packages ("hold ok installed") are
+// kept; removed/config-files/half-installed entries are dropped.
+func dpkgInstalled(status string) bool {
+ fields := strings.Fields(status)
+ if len(fields) != 3 {
+ return false
+ }
+ switch fields[2] {
+ case "installed", "triggers-awaited", "triggers-pending":
+ return true
+ default:
+ return false
+ }
+}
+
+// rpmPackages runs one epoch-bearing rpm query (a bare `rpm -qa` omits the epoch
+// that separates otherwise-identical install-only kernels). The (none) epoch is
+// stripped; gpg-pubkey pseudo-packages are filtered.
+func rpmPackages(run commandRunner) []any {
+ out := run("rpm", "-qa", "--qf", "%{NAME}|%{EPOCH}:%{VERSION}-%{RELEASE}|%{ARCH}\n")
+ var records []any
+ for line := range strings.Lines(out) {
+ fields := strings.SplitN(strings.TrimSpace(line), "|", 3)
+ if len(fields) != 3 {
+ continue
+ }
+ name, version, arch := fields[0], strings.TrimPrefix(fields[1], "(none):"), fields[2]
+ if name == "" || name == "gpg-pubkey" {
+ continue
+ }
+ if arch == "(none)" {
+ arch = ""
+ }
+ records = append(records, packageRecord(name, version, "architecture", arch))
+ }
+ sortPackages(records)
+ return records
+}
+
+// rpmDatabasePresent reports whether an rpm database directory exists, so the rpm
+// query is not spawned on dpkg/apk/pacman-only hosts.
+func rpmDatabasePresent(stat func(string) (os.FileInfo, error)) bool {
+ for _, dir := range []string{"/var/lib/rpm", "/usr/lib/sysimage/rpm"} {
+ if info, err := stat(dir); err == nil && info.IsDir() {
+ return true
+ }
+ }
+ return false
+}
+
+// apkPackages parses /lib/apk/db/installed (blank-line-separated stanzas with
+// P:/V:/A: fields).
+func apkPackages(readFile fileReader) []any {
+ data, err := readFile("/lib/apk/db/installed")
+ if err != nil {
+ return nil
+ }
+ var records []any
+ for _, stanza := range strings.Split(string(data), "\n\n") {
+ var name, version, arch string
+ for line := range strings.Lines(stanza) {
+ key, value, ok := strings.Cut(strings.TrimRight(line, "\n"), ":")
+ if !ok {
+ continue
+ }
+ switch key {
+ case "P":
+ name = value
+ case "V":
+ version = value
+ case "A":
+ arch = value
+ }
+ }
+ if name == "" || version == "" {
+ continue
+ }
+ records = append(records, packageRecord(name, version, "architecture", arch))
+ }
+ sortPackages(records)
+ return records
+}
+
+// pacmanPackages reads each /var/lib/pacman/local/*/desc entry.
+func pacmanPackages(glob pathGlobber, readFile fileReader) []any {
+ paths, err := glob("/var/lib/pacman/local/*/desc")
+ if err != nil {
+ return nil
+ }
+ var records []any
+ for _, path := range paths {
+ data, err := readFile(path)
+ if err != nil {
+ continue
+ }
+ name, version, arch := parsePacmanDesc(string(data))
+ if name == "" || version == "" {
+ continue
+ }
+ records = append(records, packageRecord(name, version, "architecture", arch))
+ }
+ sortPackages(records)
+ return records
+}
+
+// parsePacmanDesc reads the %NAME%/%VERSION%/%ARCH% sections whose value is the
+// line following the section header.
+func parsePacmanDesc(input string) (name, version, arch string) {
+ lines := strings.Split(input, "\n")
+ for i := 0; i+1 < len(lines); i++ {
+ switch strings.TrimSpace(lines[i]) {
+ case "%NAME%":
+ name = strings.TrimSpace(lines[i+1])
+ case "%VERSION%":
+ version = strings.TrimSpace(lines[i+1])
+ case "%ARCH%":
+ arch = strings.TrimSpace(lines[i+1])
+ }
+ }
+ return name, version, arch
+}
diff --git a/internal/engine/packages_bsd.go b/internal/engine/packages_bsd.go
new file mode 100644
index 00000000..bc66ce24
--- /dev/null
+++ b/internal/engine/packages_bsd.go
@@ -0,0 +1,162 @@
+package engine
+
+import (
+ "os"
+ "strings"
+)
+
+// pkgngPackages queries the FreeBSD/DragonFly pkgng database with a single cheap
+// `pkg query`, capturing name, version, and the package ABI (%q), which encodes
+// the target architecture (e.g. FreeBSD:14:amd64, dragonfly:6.4:x86:64, or a
+// FreeBSD:14:* wildcard for architecture-independent packages).
+func pkgngPackages(run commandRunner) []any {
+ // pkgng lives in the ports prefix, which is outside the engine's trusted
+ // command PATH (/usr/sbin:/usr/bin:/sbin:/bin); call it by absolute path,
+ // the same way the DMI probe reaches /usr/local/sbin/dmidecode. DragonFly
+ // has no /usr/sbin/pkg bootstrap stub, so only the ports path is portable.
+ out := run("/usr/local/sbin/pkg", "query", "-a", "%n|%v|%q")
+ var records []any
+ for line := range strings.Lines(out) {
+ fields := strings.SplitN(strings.TrimSpace(line), "|", 3)
+ if len(fields) != 3 {
+ continue
+ }
+ name, version, abi := fields[0], fields[1], fields[2]
+ if name == "" || version == "" {
+ continue
+ }
+ records = append(records, packageRecord(name, version, "architecture", abi))
+ }
+ sortPackages(records)
+ return records
+}
+
+// openbsdPackages enumerates the /var/db/pkg/-[-flavor]
+// subdirectories that record every installed OpenBSD package, deriving the stem
+// and version from the directory name and the architecture from each package's
+// +CONTENTS @arch annotation.
+func openbsdPackages(readDir func(string) ([]os.DirEntry, error), readFile fileReader) []any {
+ entries, err := readDir("/var/db/pkg")
+ if err != nil {
+ return nil
+ }
+ var records []any
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ name, version := splitBSDPackageName(entry.Name())
+ if name == "" || version == "" {
+ continue
+ }
+ arch := bsdContentsArch(readFile, "/var/db/pkg/"+entry.Name()+"/+CONTENTS")
+ records = append(records, packageRecord(name, version, "architecture", arch))
+ }
+ sortPackages(records)
+ return records
+}
+
+// pkgsrcPackages enumerates the installed pkgsrc packages recorded as
+// - subdirectories of PKG_DBDIR. It is NetBSD's primary source
+// and an illumos/SmartOS and DragonFly secondary (ADR-0014). PKG_DBDIR is not
+// hardcoded to a single path: the standard candidates are probed in order —
+// the pkgsrc default /usr/pkg/pkgdb, the SmartOS prefix /opt/local/pkgdb (and
+// its pre-2019 /opt/local/pkg), then the legacy /var/db/pkg — using the first
+// that lists any package entries. On a pkgng host /var/db/pkg holds only
+// sqlite files (no - directories), so it contributes nothing.
+func pkgsrcPackages(readDir func(string) ([]os.DirEntry, error)) []any {
+ for _, dbdir := range []string{"/usr/pkg/pkgdb", "/opt/local/pkgdb", "/opt/local/pkg", "/var/db/pkg"} {
+ entries, err := readDir(dbdir)
+ if err != nil {
+ continue
+ }
+ var records []any
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ name, version := splitBSDPackageName(entry.Name())
+ if name == "" || version == "" {
+ continue
+ }
+ records = append(records, packageRecord(name, version))
+ }
+ if len(records) > 0 {
+ sortPackages(records)
+ return records
+ }
+ }
+ return nil
+}
+
+// ipsPackages lists the packages installed in the local illumos IPS image with
+// `pkg list -H` (no header row, no network refresh). Columns are
+// NAME [ (PUBLISHER) ] VERSION IFO, where the parenthesised publisher only
+// appears for packages from a non-preferred publisher. The FMRI stem is the
+// name, the branch-bearing version column is the version, and the trailing IFO
+// flags are ignored.
+func ipsPackages(run commandRunner) []any {
+ out := run("pkg", "list", "-H")
+ var records []any
+ for line := range strings.Lines(out) {
+ fields := strings.Fields(line)
+ if len(fields) < 2 {
+ continue
+ }
+ name, rest := fields[0], fields[1:]
+ if strings.HasPrefix(rest[0], "(") {
+ rest = rest[1:]
+ }
+ if len(rest) == 0 {
+ continue
+ }
+ version := rest[0]
+ if name == "" || version == "" {
+ continue
+ }
+ records = append(records, packageRecord(name, version))
+ }
+ sortPackages(records)
+ return records
+}
+
+// splitBSDPackageName splits an OpenBSD/pkgsrc package directory name into its
+// stem and version. Both encode the version as the last hyphen-separated
+// component (following a non-empty stem) whose token begins with a digit; any
+// trailing hyphen-separated tokens are flavors and are discarded. Choosing the
+// last such component keeps names whose final stem token is numeric intact
+// (e.g. gcc-11-11.2.0 -> gcc-11 / 11.2.0), while still handling flavored names
+// (vim-9.0.2035-no_x11 -> vim / 9.0.2035). Returns empty strings when no version
+// component is present (e.g. the pkgdb.byfile.db index file).
+func splitBSDPackageName(dir string) (name, version string) {
+ parts := strings.Split(dir, "-")
+ versionIdx := -1
+ for i := 1; i < len(parts); i++ {
+ if token := parts[i]; token != "" && token[0] >= '0' && token[0] <= '9' {
+ versionIdx = i
+ }
+ }
+ if versionIdx < 0 {
+ return "", ""
+ }
+ return strings.Join(parts[:versionIdx], "-"), parts[versionIdx]
+}
+
+// bsdContentsArch returns the architecture recorded in an OpenBSD +CONTENTS
+// packing list (the @arch annotation). The arch-independent "*" marker and a
+// missing file both yield an empty string.
+func bsdContentsArch(readFile fileReader, path string) string {
+ data, err := readFile(path)
+ if err != nil {
+ return ""
+ }
+ for line := range strings.Lines(string(data)) {
+ if rest, ok := strings.CutPrefix(strings.TrimRight(line, "\n"), "@arch "); ok {
+ if arch := strings.TrimSpace(rest); arch != "*" {
+ return arch
+ }
+ return ""
+ }
+ }
+ return ""
+}
diff --git a/internal/engine/packages_bsd_test.go b/internal/engine/packages_bsd_test.go
new file mode 100644
index 00000000..7a7be8a9
--- /dev/null
+++ b/internal/engine/packages_bsd_test.go
@@ -0,0 +1,253 @@
+package engine
+
+import (
+ "os"
+ "reflect"
+ "testing"
+)
+
+// pkgngQueryFixtures are real `pkg query -a '%n|%v|%q'` outputs captured from the
+// nlab FreeBSD and DragonFly guests. They exercise PORTEPOCH commas in versions
+// (flac|1.4.3,1), ABIs with internal colons (dragonfly:6.4:x86:64), and the
+// architecture-independent "*" ABI wildcard.
+func TestPkgngPackages(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ out string
+ want []any
+ }{
+ {
+ name: "dragonfly",
+ out: "bash|5.2.15|dragonfly:6.4:x86:64\n" +
+ "ca_root_nss|3.89.1|dragonfly:6.4:*\n" +
+ "flac|1.4.3,1|dragonfly:6.4:x86:64\n" +
+ "libedit|3.1.20221030,1|dragonfly:6.4:x86:64\n",
+ want: []any{
+ map[string]any{"name": "bash", "version": "5.2.15", "architecture": "dragonfly:6.4:x86:64"},
+ map[string]any{"name": "ca_root_nss", "version": "3.89.1", "architecture": "dragonfly:6.4:*"},
+ map[string]any{"name": "flac", "version": "1.4.3,1", "architecture": "dragonfly:6.4:x86:64"},
+ map[string]any{"name": "libedit", "version": "3.1.20221030,1", "architecture": "dragonfly:6.4:x86:64"},
+ },
+ },
+ {
+ name: "freebsd",
+ out: "firstboot-freebsd-update|1.4|FreeBSD:14:*\n" +
+ "pkg|2.1.2|FreeBSD:14:amd64\n",
+ want: []any{
+ map[string]any{"name": "firstboot-freebsd-update", "version": "1.4", "architecture": "FreeBSD:14:*"},
+ map[string]any{"name": "pkg", "version": "2.1.2", "architecture": "FreeBSD:14:amd64"},
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := pkgngPackages(func(name string, args ...string) string {
+ if name != "/usr/local/sbin/pkg" || len(args) == 0 || args[0] != "query" {
+ t.Fatalf("command = %q %v", name, args)
+ }
+ return tt.out
+ })
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Fatalf("pkgngPackages() = %#v\nwant %#v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPkgngPackagesEmptyYieldsNothing(t *testing.T) {
+ t.Parallel()
+ if got := pkgngPackages(func(string, ...string) string { return "" }); got != nil {
+ t.Fatalf("pkgngPackages(empty) = %#v, want nil", got)
+ }
+}
+
+// openbsdContentsFixture is a trimmed real bash-5.2.15 +CONTENTS; the @arch
+// annotation carries the architecture while the @comment line (which also
+// contains the substring "arch") must not be mistaken for it.
+const openbsdContentsFixture = `@name bash-5.2.15
+@option manual-installation
+@comment pkgpath=shells/bash ftp=yes
+@arch amd64
++DESC
+`
+
+// TestOpenbsdPackages enumerates real /var/db/pkg entry names (incl. the
+// vim-9.0.2035-no_x11 flavor case and the gettext-runtime dashed stem), skips a
+// non-directory index/lock entry, and reads architecture from +CONTENTS,
+// omitting it for the architecture-independent "*" package.
+func TestOpenbsdPackages(t *testing.T) {
+ t.Parallel()
+ entries := []os.DirEntry{
+ fakeDirEntry{name: "bash-5.2.15", mode: os.ModeDir, isDir: true},
+ fakeDirEntry{name: "gettext-runtime-0.22.2", mode: os.ModeDir, isDir: true},
+ fakeDirEntry{name: "vim-9.0.2035-no_x11", mode: os.ModeDir, isDir: true},
+ fakeDirEntry{name: "quirks-6.160", mode: os.ModeDir, isDir: true},
+ fakeDirEntry{name: ".lock", isDir: false},
+ }
+ contents := map[string]string{
+ "/var/db/pkg/bash-5.2.15/+CONTENTS": openbsdContentsFixture,
+ "/var/db/pkg/gettext-runtime-0.22.2/+CONTENTS": "@name gettext-runtime-0.22.2\n@arch amd64\n",
+ "/var/db/pkg/vim-9.0.2035-no_x11/+CONTENTS": "@name vim-9.0.2035-no_x11\n@option manual-installation\n@arch amd64\n",
+ "/var/db/pkg/quirks-6.160/+CONTENTS": "@name quirks-6.160\n@arch *\n",
+ }
+ got := openbsdPackages(
+ func(path string) ([]os.DirEntry, error) {
+ if path != "/var/db/pkg" {
+ t.Fatalf("readDir path = %q", path)
+ }
+ return entries, nil
+ },
+ func(path string) ([]byte, error) {
+ data, ok := contents[path]
+ if !ok {
+ t.Fatalf("readFile path = %q", path)
+ }
+ return []byte(data), nil
+ },
+ )
+ want := []any{
+ map[string]any{"name": "bash", "version": "5.2.15", "architecture": "amd64"},
+ map[string]any{"name": "gettext-runtime", "version": "0.22.2", "architecture": "amd64"},
+ map[string]any{"name": "quirks", "version": "6.160"},
+ map[string]any{"name": "vim", "version": "9.0.2035", "architecture": "amd64"},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("openbsdPackages() = %#v\nwant %#v", got, want)
+ }
+}
+
+func TestOpenbsdPackagesAbsentDatabaseYieldsNothing(t *testing.T) {
+ t.Parallel()
+ got := openbsdPackages(
+ func(string) ([]os.DirEntry, error) { return nil, os.ErrNotExist },
+ func(string) ([]byte, error) { return nil, os.ErrNotExist },
+ )
+ if got != nil {
+ t.Fatalf("openbsdPackages(absent) = %#v, want nil", got)
+ }
+}
+
+// TestPkgsrcPackages discovers PKG_DBDIR by probing the standard candidates in
+// order. Entry names are real NetBSD /usr/pkg/pkgdb names, including the
+// pkg_install underscore stem, the vim-share dashed stem, and the
+// pkgdb.byfile.db index (a plain file that must be skipped).
+func TestPkgsrcPackages(t *testing.T) {
+ t.Parallel()
+ entries := []os.DirEntry{
+ fakeDirEntry{name: "bash-5.2.21nb1", mode: os.ModeDir, isDir: true},
+ fakeDirEntry{name: "ca-certificates-20230311nb3", mode: os.ModeDir, isDir: true},
+ fakeDirEntry{name: "pkg_install-20211115nb1", mode: os.ModeDir, isDir: true},
+ fakeDirEntry{name: "vim-share-9.0.2122", mode: os.ModeDir, isDir: true},
+ fakeDirEntry{name: "pkgdb.byfile.db", isDir: false},
+ }
+ want := []any{
+ map[string]any{"name": "bash", "version": "5.2.21nb1"},
+ map[string]any{"name": "ca-certificates", "version": "20230311nb3"},
+ map[string]any{"name": "pkg_install", "version": "20211115nb1"},
+ map[string]any{"name": "vim-share", "version": "9.0.2122"},
+ }
+ tests := []struct {
+ name string
+ dbdir string
+ }{
+ {name: "default pkgdb", dbdir: "/usr/pkg/pkgdb"},
+ {name: "smartos pkgdb", dbdir: "/opt/local/pkgdb"},
+ {name: "smartos legacy", dbdir: "/opt/local/pkg"},
+ {name: "legacy fallback", dbdir: "/var/db/pkg"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := pkgsrcPackages(func(path string) ([]os.DirEntry, error) {
+ if path == tt.dbdir {
+ return entries, nil
+ }
+ return nil, os.ErrNotExist
+ })
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("pkgsrcPackages() = %#v\nwant %#v", got, want)
+ }
+ })
+ }
+}
+
+func TestPkgsrcPackagesAbsentDatabaseYieldsNothing(t *testing.T) {
+ t.Parallel()
+ got := pkgsrcPackages(func(string) ([]os.DirEntry, error) { return nil, os.ErrNotExist })
+ if got != nil {
+ t.Fatalf("pkgsrcPackages(absent) = %#v, want nil", got)
+ }
+}
+
+// ipsListFixture is real `pkg list -H` output from the nlab OmniOS guest (3
+// columns: NAME VERSION IFO). The image installs only from the preferred
+// publisher, so no package renders a "(publisher)" column; the system/library
+// line is a format-accurate synthetic entry from the extra.omnios publisher to
+// exercise the parenthesised-publisher branch.
+const ipsListFixture = `SUNWcs 0.5.11-151058.0 i--
+compress/xz 5.8.3-151058.0 i--
+database/sqlite-3 3.51.3-151058.0 i--
+developer/macro/cpp 20240422-151058.0 i--
+system/library (extra.omnios) 1.2.3-151046.0 i--
+`
+
+func TestIpsPackages(t *testing.T) {
+ t.Parallel()
+ got := ipsPackages(func(name string, args ...string) string {
+ if name != "pkg" || len(args) < 2 || args[0] != "list" || args[1] != "-H" {
+ t.Fatalf("command = %q %v", name, args)
+ }
+ return ipsListFixture
+ })
+ want := []any{
+ map[string]any{"name": "SUNWcs", "version": "0.5.11-151058.0"},
+ map[string]any{"name": "compress/xz", "version": "5.8.3-151058.0"},
+ map[string]any{"name": "database/sqlite-3", "version": "3.51.3-151058.0"},
+ map[string]any{"name": "developer/macro/cpp", "version": "20240422-151058.0"},
+ map[string]any{"name": "system/library", "version": "1.2.3-151046.0"},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("ipsPackages() = %#v\nwant %#v", got, want)
+ }
+}
+
+func TestIpsPackagesEmptyYieldsNothing(t *testing.T) {
+ t.Parallel()
+ if got := ipsPackages(func(string, ...string) string { return "" }); got != nil {
+ t.Fatalf("ipsPackages(empty) = %#v, want nil", got)
+ }
+}
+
+// TestSplitBsdPackageName covers the OpenBSD/pkgsrc -[-flavor]
+// convention: the version is the last hyphen component (after a non-empty stem)
+// starting with a digit, keeping dashed and numeric-suffixed stems intact.
+func TestSplitBsdPackageName(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ dir string
+ wantName string
+ wantVersion string
+ }{
+ {"autoconf-2.69p3", "autoconf", "2.69p3"},
+ {"quirks-7.55", "quirks", "7.55"},
+ {"xz-5.4.5", "xz", "5.4.5"},
+ {"gettext-runtime-0.22.2", "gettext-runtime", "0.22.2"},
+ {"vim-9.0.2035-no_x11", "vim", "9.0.2035"},
+ {"pcre2-10.37p1", "pcre2", "10.37p1"},
+ {"pkg_install-20211115nb1", "pkg_install", "20211115nb1"},
+ {"gcc-11-11.2.0", "gcc-11", "11.2.0"},
+ {"pkgdb.byfile.db", "", ""},
+ {"quirks", "", ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.dir, func(t *testing.T) {
+ t.Parallel()
+ name, version := splitBSDPackageName(tt.dir)
+ if name != tt.wantName || version != tt.wantVersion {
+ t.Fatalf("splitBSDPackageName(%q) = (%q, %q), want (%q, %q)", tt.dir, name, version, tt.wantName, tt.wantVersion)
+ }
+ })
+ }
+}
diff --git a/internal/engine/packages_extra.go b/internal/engine/packages_extra.go
new file mode 100644
index 00000000..124b4745
--- /dev/null
+++ b/internal/engine/packages_extra.go
@@ -0,0 +1,253 @@
+package engine
+
+import (
+ "os"
+ "strings"
+)
+
+// This file adds the "extra" Linux package sources (snap, flatpak, nix) to the
+// packages fact (ADR-0014). Each reader is a pure function over injected probe
+// output and follows the packages.go conventions: {name, version, ...} records
+// via packageRecord, deterministic ordering via sortPackages, and nil when the
+// source is absent or empty. Sources are never merged.
+//
+// Wiring (added to packagesCoreFacts' `case "linux":`, gated so a tool is only
+// spawned when its state directory exists):
+//
+// if snapdPresent(s.stat) {
+// add("snap", snapPackages(s.commandOutput))
+// }
+// if flatpakPresent(s.stat) {
+// add("flatpak", flatpakPackages(s.commandOutput))
+// }
+// if nixProfilePresent(s.stat) {
+// add("nix", nixPackages(s.commandOutput))
+// }
+
+// snapPackages parses `snap list` (columns: Name Version Rev Tracking Publisher
+// Notes). The header row is required: its absence means snap printed "No snaps
+// are installed yet." and there is nothing to record.
+func snapPackages(run commandRunner) []any {
+ out := run("snap", "list")
+ var records []any
+ sawHeader := false
+ for line := range strings.Lines(out) {
+ fields := strings.Fields(line)
+ if len(fields) < 2 {
+ continue
+ }
+ if !sawHeader {
+ if fields[0] == "Name" && fields[1] == "Version" {
+ sawHeader = true
+ }
+ continue
+ }
+ records = append(records, packageRecord(fields[0], fields[1]))
+ }
+ sortPackages(records)
+ return records
+}
+
+// flatpakPackages parses `flatpak list --columns=application,version,arch,branch`
+// (the system installation), whose rows are tab-separated with no header. The
+// application id is the record name; arch and branch are identity fields —
+// branch is load-bearing, not decorative: the same application id can be
+// installed twice with an identical version and arch, distinguishable only by
+// branch (observed live: GL.default 25.08 vs 25.08-extra). Extensions with no
+// version (e.g. codecs-extra) are dropped by the name+version invariant.
+func flatpakPackages(run commandRunner) []any {
+ out := run("flatpak", "list", "--columns=application,version,arch,branch")
+ var records []any
+ for line := range strings.Lines(out) {
+ fields := strings.Split(strings.TrimRight(line, "\n"), "\t")
+ if len(fields) < 2 {
+ continue
+ }
+ name, version := strings.TrimSpace(fields[0]), strings.TrimSpace(fields[1])
+ if name == "" || version == "" {
+ continue
+ }
+ var arch, branch string
+ if len(fields) >= 3 {
+ arch = strings.TrimSpace(fields[2])
+ }
+ if len(fields) >= 4 {
+ branch = strings.TrimSpace(fields[3])
+ }
+ records = append(records, packageRecord(name, version, "architecture", arch, "branch", branch))
+ }
+ sortPackages(records)
+ return records
+}
+
+// nixPackages enumerates the packages that make up the running NixOS system
+// environment via `nix-store -q --references /run/current-system/sw` (the direct
+// references of the system-path buildEnv). This is the installed profile set, not
+// the whole /nix/store. nix-env -q cannot be used here: the NixOS system-path is
+// a buildEnv without the manifest.nix nix-env requires, so it lists nothing.
+//
+// Each reference is a store path "/nix/store/--[-