From 899b2432ad26497d7fbb9baabc16453c6a2d5423 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 1 Oct 2026 09:30:01 +0100 Subject: [PATCH] Add bundled or derived role predicate --- README.md | 13 +++++++++++++ roles.go | 7 +++++++ roles_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/README.md b/README.md index f15f317..42f52c9 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,11 @@ and notice files. Roles follow a fixed order; evidence follows ancestor order and then filename matches. A `source` role describes layout and does not establish compilability or authorship. +A role set cannot establish whether a file is first-party code. `main.go` +has no roles, and `scripts/deploy.py` has `tooling` without `source`. +Requiring `source` would drop both from analysis, even when they were written +for the project. + ## Usage ```go @@ -136,6 +141,14 @@ no file reads or evidence allocations and does not require a second traversal. `Match` supports concurrent calls; content analysis and directory exclusion policies remain with the caller. +`Set.BundledOrDerived()` reports whether any of `vendor`, `fixture`, `cache` +or `build-output` is present. Callers can use this grouping when filtering +project evidence. `generated` alone does not qualify, so generated lockfiles +remain eligible as dependency evidence. Test and tooling paths also remain +eligible unless they carry one of those four roles. A false result does not +establish first-party ownership, and callers scanning licences or security +issues may still need files for which it returns true. + Paths use `/` separators. A trailing slash denotes a directory, so `vendor/` is a vendor directory while `vendor` alone is a filename. Empty paths, absolute paths, NUL bytes, repeated separators and `.` or `..` components diff --git a/roles.go b/roles.go index 5f18bd7..eaf3704 100644 --- a/roles.go +++ b/roles.go @@ -72,6 +72,13 @@ func (s *Set) UnmarshalJSON(data []byte) error { // Has reports whether the set contains role. Unknown role names return false. func (s Set) Has(role Role) bool { return s&roleBit(role) != 0 } +// BundledOrDerived reports whether the set contains Vendor, Fixture, Cache or +// BuildOutput. Generated alone does not qualify. The result does not establish +// ownership or whether a file is safe to skip. +func (s Set) BundledOrDerived() bool { + return s.Has(Vendor) || s.Has(Fixture) || s.Has(Cache) || s.Has(BuildOutput) +} + // List returns the roles in deterministic order. func (s Set) List() []Role { if s == 0 { diff --git a/roles_test.go b/roles_test.go index aab3e55..227b9d9 100644 --- a/roles_test.go +++ b/roles_test.go @@ -140,6 +140,56 @@ func TestEmptyResultJSON(t *testing.T) { } } +func TestBundledOrDerived(t *testing.T) { + for _, tc := range []struct { + path string + want bool + }{ + {"vendor/sqlite/LICENSE", true}, + {"vendor/lib/main.go", true}, + {"fixtures/input.json", true}, + {"testdata/package-lock.json", true}, + {".pytest_cache/v/cache/nodeids", true}, + {"CMakeFiles/app.dir/main.o", true}, + {"main.go", false}, + {"src/parser.go", false}, + {"alembic.ini", false}, + {"scripts/deploy.py", false}, + {"tests/test_app.py", false}, + {"internal/parser_test.go", false}, + {"examples/client/main.go", false}, + {"service.pb.go", false}, + {"package-lock.json", false}, + {"src/app.min.js", false}, + } { + t.Run(tc.path, func(t *testing.T) { + set, err := roles.Match(tc.path) + if err != nil { + t.Fatal(err) + } + if got := set.BundledOrDerived(); got != tc.want { + t.Errorf("BundledOrDerived() = %v for roles %v, want %v", got, set.List(), tc.want) + } + }) + } +} + +func TestBundledOrDerivedVendorContext(t *testing.T) { + classifier, err := roles.New([]roles.VendorRoot{{Path: "deps/local"}}) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"deps/local/", "deps/local/src/lib.rs"} { + set, err := classifier.Match(path) + if err != nil { + t.Fatal(err) + } + if !set.BundledOrDerived() { + t.Errorf("configured vendor path %q did not qualify: %v", path, set.List()) + } + } +} + func TestSetJSON(t *testing.T) { set, err := roles.Match("vendor/LICENSES/NOTICE") if err != nil {