github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/internal/pkgtest/test_generic_parser.go (about)

     1  package pkgtest
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"path/filepath"
     9  	"reflect"
    10  	"runtime"
    11  	"sort"
    12  	"strings"
    13  	"sync"
    14  	"testing"
    15  
    16  	"github.com/google/go-cmp/cmp"
    17  	"github.com/google/go-cmp/cmp/cmpopts"
    18  	"github.com/google/licensecheck"
    19  	"github.com/stretchr/testify/assert"
    20  	"github.com/stretchr/testify/require"
    21  
    22  	stereofile "github.com/anchore/stereoscope/pkg/file"
    23  	"github.com/anchore/stereoscope/pkg/imagetest"
    24  	"github.com/anchore/syft/internal/cmptest"
    25  	"github.com/anchore/syft/internal/licenses"
    26  	"github.com/anchore/syft/internal/relationship"
    27  	"github.com/anchore/syft/syft/artifact"
    28  	"github.com/anchore/syft/syft/file"
    29  	"github.com/anchore/syft/syft/internal/fileresolver"
    30  	"github.com/anchore/syft/syft/linux"
    31  	"github.com/anchore/syft/syft/pkg"
    32  	"github.com/anchore/syft/syft/pkg/cataloger/generic"
    33  	"github.com/anchore/syft/syft/source"
    34  	"github.com/anchore/syft/syft/source/directorysource"
    35  	"github.com/anchore/syft/syft/source/filesource"
    36  	"github.com/anchore/syft/syft/source/stereoscopesource"
    37  )
    38  
    39  var (
    40  	once           sync.Once
    41  	licenseScanner *licenses.Scanner
    42  )
    43  
    44  type CatalogTester struct {
    45  	expectedPkgs                   []pkg.Package
    46  	expectedRelationships          []artifact.Relationship
    47  	assertResultExpectations       bool
    48  	expectedPathResponses          []string // this is a minimum set, the resolver may return more that just this list
    49  	expectedContentQueries         []string // this is a full set, any other queries are unexpected (and will fail the test)
    50  	ignoreUnfulfilledPathResponses map[string][]string
    51  	ignoreAnyUnfulfilledPaths      []string
    52  	env                            *generic.Environment
    53  	reader                         file.LocationReadCloser
    54  	resolver                       file.Resolver
    55  	wantErr                        require.ErrorAssertionFunc
    56  	compareOptions                 []cmp.Option
    57  	locationComparer               cmptest.LocationComparer
    58  	licenseComparer                cmptest.LicenseComparer
    59  	packageStringer                func(pkg.Package) string
    60  	customAssertions               []func(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship)
    61  	context                        context.Context
    62  	skipTestObservations           bool
    63  }
    64  
    65  func Context() context.Context {
    66  	once.Do(func() {
    67  		// most of the time in testing is initializing the scanner. Let's do that just once
    68  		sc := &licenses.ScannerConfig{Scanner: licensecheck.Scan, CoverageThreshold: 75}
    69  		scanner, err := licenses.NewScanner(sc)
    70  		if err != nil {
    71  			panic("unable to setup licences scanner for testing")
    72  		}
    73  		licenseScanner = &scanner
    74  	})
    75  
    76  	return licenses.SetContextLicenseScanner(context.Background(), *licenseScanner)
    77  }
    78  
    79  func NewCatalogTester() *CatalogTester {
    80  	return &CatalogTester{
    81  		context:          Context(),
    82  		locationComparer: cmptest.DefaultLocationComparer,
    83  		licenseComparer:  cmptest.DefaultLicenseComparer,
    84  		packageStringer:  stringPackage,
    85  		resolver:         fileresolver.Empty{},
    86  		ignoreUnfulfilledPathResponses: map[string][]string{
    87  			"FilesByPath": {
    88  				// most catalogers search for a linux release, which will not be fulfilled in testing
    89  				"/etc/os-release",
    90  				"/usr/lib/os-release",
    91  				"/etc/system-release-cpe",
    92  				"/etc/redhat-release",
    93  				"/bin/busybox",
    94  			},
    95  		},
    96  	}
    97  }
    98  
    99  func (p *CatalogTester) WithContext(ctx context.Context) *CatalogTester {
   100  	p.context = ctx
   101  	return p
   102  }
   103  
   104  func (p *CatalogTester) FromDirectory(t *testing.T, path string) *CatalogTester {
   105  	t.Helper()
   106  
   107  	if path == "" {
   108  		return p
   109  	}
   110  
   111  	s, err := directorysource.NewFromPath(path)
   112  	require.NoError(t, err)
   113  
   114  	resolver, err := s.FileResolver(source.AllLayersScope)
   115  	require.NoError(t, err)
   116  
   117  	p.resolver = resolver
   118  	return p
   119  }
   120  
   121  func (p *CatalogTester) FromFileSource(t *testing.T, path string) *CatalogTester {
   122  	t.Helper()
   123  
   124  	s, err := filesource.NewFromPath(path)
   125  	require.NoError(t, err)
   126  	resolver, err := s.FileResolver(source.AllLayersScope)
   127  	require.NoError(t, err)
   128  
   129  	p.resolver = resolver
   130  	return p
   131  }
   132  
   133  func (p *CatalogTester) FromFile(t *testing.T, path string) *CatalogTester {
   134  	t.Helper()
   135  
   136  	if path == "" {
   137  		return p
   138  	}
   139  
   140  	absPath, err := filepath.Abs(path)
   141  	require.NoError(t, err)
   142  
   143  	fixture, err := os.Open(path)
   144  	require.NoError(t, err)
   145  
   146  	p.reader = file.LocationReadCloser{
   147  		Location:   file.NewVirtualLocationFromDirectory(fixture.Name(), fixture.Name(), *stereofile.NewFileReference(stereofile.Path(absPath))),
   148  		ReadCloser: fixture,
   149  	}
   150  	return p
   151  }
   152  
   153  func (p *CatalogTester) FromString(location, data string) *CatalogTester {
   154  	p.reader = file.LocationReadCloser{
   155  		Location:   file.NewLocation(location),
   156  		ReadCloser: io.NopCloser(strings.NewReader(data)),
   157  	}
   158  	return p
   159  }
   160  
   161  func (p *CatalogTester) WithLinuxRelease(r linux.Release) *CatalogTester {
   162  	if p.env == nil {
   163  		p.env = &generic.Environment{}
   164  	}
   165  	p.env.LinuxRelease = &r
   166  	return p
   167  }
   168  
   169  func (p *CatalogTester) WithEnv(env *generic.Environment) *CatalogTester {
   170  	p.env = env
   171  	return p
   172  }
   173  
   174  func (p *CatalogTester) WithError() *CatalogTester {
   175  	p.wantErr = require.Error
   176  	return p
   177  }
   178  
   179  func (p *CatalogTester) WithErrorAssertion(a require.ErrorAssertionFunc) *CatalogTester {
   180  	p.wantErr = a
   181  	return p
   182  }
   183  
   184  func (p *CatalogTester) WithResolver(r file.Resolver) *CatalogTester {
   185  	p.resolver = r
   186  	return p
   187  }
   188  
   189  func (p *CatalogTester) WithImageResolver(t *testing.T, fixtureName string) *CatalogTester {
   190  	t.Helper()
   191  
   192  	if fixtureName == "" {
   193  		return p
   194  	}
   195  
   196  	img := imagetest.GetFixtureImage(t, "docker-archive", fixtureName)
   197  
   198  	s := stereoscopesource.New(img, stereoscopesource.ImageConfig{
   199  		Reference: fixtureName,
   200  	})
   201  
   202  	r, err := s.FileResolver(source.SquashedScope)
   203  	require.NoError(t, err)
   204  	p.resolver = r
   205  	return p
   206  }
   207  
   208  func (p *CatalogTester) ExpectsAssertion(a func(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship)) *CatalogTester {
   209  	p.customAssertions = append(p.customAssertions, a)
   210  	return p
   211  }
   212  
   213  func (p *CatalogTester) IgnoreLocationLayer() *CatalogTester {
   214  	p.locationComparer = cmptest.LocationComparerWithoutLayer
   215  	p.licenseComparer = cmptest.LicenseComparerWithoutLocationLayer
   216  	return p
   217  }
   218  
   219  func (p *CatalogTester) IgnorePackageFields(fields ...string) *CatalogTester {
   220  	p.compareOptions = append(p.compareOptions, cmpopts.IgnoreFields(pkg.Package{}, fields...))
   221  	return p
   222  }
   223  
   224  func (p *CatalogTester) WithCompareOptions(opts ...cmp.Option) *CatalogTester {
   225  	p.compareOptions = append(p.compareOptions, opts...)
   226  	return p
   227  }
   228  
   229  func (p *CatalogTester) Expects(pkgs []pkg.Package, relationships []artifact.Relationship) *CatalogTester {
   230  	p.assertResultExpectations = true
   231  	p.expectedPkgs = pkgs
   232  	p.expectedRelationships = relationships
   233  	return p
   234  }
   235  
   236  func (p *CatalogTester) WithPackageStringer(fn func(pkg.Package) string) *CatalogTester {
   237  	p.packageStringer = fn
   238  	return p
   239  }
   240  
   241  func (p *CatalogTester) ExpectsPackageStrings(expected []string) *CatalogTester {
   242  	return p.ExpectsAssertion(func(t *testing.T, pkgs []pkg.Package, _ []artifact.Relationship) {
   243  		diffPackages(t, expected, pkgs, p.packageStringer)
   244  	})
   245  }
   246  
   247  func (p *CatalogTester) ExpectsRelationshipStrings(expected []string) *CatalogTester {
   248  	return p.ExpectsAssertion(func(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship) {
   249  		diffRelationships(t, expected, relationships, pkgs, p.packageStringer)
   250  	})
   251  }
   252  
   253  func (p *CatalogTester) ExpectsResolverPathResponses(locations []string) *CatalogTester {
   254  	p.expectedPathResponses = locations
   255  	return p
   256  }
   257  
   258  func (p *CatalogTester) ExpectsResolverContentQueries(locations []string) *CatalogTester {
   259  	p.expectedContentQueries = locations
   260  	return p
   261  }
   262  
   263  func (p *CatalogTester) IgnoreUnfulfilledPathResponses(paths ...string) *CatalogTester {
   264  	p.ignoreAnyUnfulfilledPaths = append(p.ignoreAnyUnfulfilledPaths, paths...)
   265  	return p
   266  }
   267  
   268  func (p *CatalogTester) WithoutTestObserver() *CatalogTester {
   269  	p.skipTestObservations = true
   270  	return p
   271  }
   272  
   273  func (p *CatalogTester) TestParser(t *testing.T, parser generic.Parser) {
   274  	t.Helper()
   275  	pkgs, relationships, err := parser(p.context, p.resolver, p.env, p.reader)
   276  
   277  	// only test for errors if explicitly requested
   278  	if p.wantErr != nil {
   279  		p.wantErr(t, err)
   280  	}
   281  
   282  	// track metadata types for cataloger discovery
   283  	p.trackParserMetadata(t, parser, pkgs, relationships)
   284  
   285  	p.assertPkgs(t, pkgs, relationships)
   286  }
   287  
   288  func (p *CatalogTester) TestCataloger(t *testing.T, cataloger pkg.Cataloger) {
   289  	t.Helper()
   290  
   291  	resolver := NewObservingResolver(p.resolver)
   292  
   293  	pkgs, relationships, err := cataloger.Catalog(p.context, resolver)
   294  
   295  	// this is a minimum set, the resolver may return more that just this list
   296  	for _, path := range p.expectedPathResponses {
   297  		assert.Truef(t, resolver.ObservedPathResponses(path), "expected path query for %q was not observed", path)
   298  	}
   299  
   300  	// this is a full set, any other queries are unexpected (and will fail the test)
   301  	if len(p.expectedContentQueries) > 0 {
   302  		assert.ElementsMatchf(t, p.expectedContentQueries, resolver.AllContentQueries(), "unexpected content queries observed: diff %s", cmp.Diff(p.expectedContentQueries, resolver.AllContentQueries()))
   303  	}
   304  
   305  	// only test for errors if explicitly requested
   306  	if p.wantErr != nil {
   307  		p.wantErr(t, err)
   308  	}
   309  
   310  	// track metadata types for cataloger discovery
   311  	p.trackCatalogerMetadata(t, cataloger, pkgs, relationships)
   312  
   313  	if p.assertResultExpectations {
   314  		p.assertPkgs(t, pkgs, relationships)
   315  	}
   316  
   317  	for _, a := range p.customAssertions {
   318  		a(t, pkgs, relationships)
   319  	}
   320  
   321  	if !p.assertResultExpectations && len(p.customAssertions) == 0 && p.wantErr == nil {
   322  		resolver.PruneUnfulfilledPathResponses(p.ignoreUnfulfilledPathResponses, p.ignoreAnyUnfulfilledPaths...)
   323  
   324  		// if we aren't testing the results, we should focus on what was searched for (for glob-centric tests)
   325  		assert.Falsef(t, resolver.HasUnfulfilledPathRequests(), "unfulfilled path requests: \n%v", resolver.PrettyUnfulfilledPathRequests())
   326  	}
   327  }
   328  
   329  func (p *CatalogTester) assertPkgs(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship) {
   330  	t.Helper()
   331  
   332  	p.compareOptions = append(p.compareOptions, cmptest.BuildOptions(p.licenseComparer, p.locationComparer)...)
   333  
   334  	{
   335  		r := cmptest.NewDiffReporter()
   336  		var opts []cmp.Option
   337  
   338  		opts = append(opts, p.compareOptions...)
   339  		opts = append(opts, cmp.Reporter(&r))
   340  
   341  		// order should not matter
   342  		pkg.Sort(p.expectedPkgs)
   343  		pkg.Sort(pkgs)
   344  
   345  		if diff := cmp.Diff(p.expectedPkgs, pkgs, opts...); diff != "" {
   346  			t.Log("Specific Differences:\n" + r.String())
   347  			t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff)
   348  		}
   349  	}
   350  	{
   351  		r := cmptest.NewDiffReporter()
   352  		var opts []cmp.Option
   353  
   354  		opts = append(opts, p.compareOptions...)
   355  		opts = append(opts, cmp.Reporter(&r))
   356  
   357  		// ignore the "FoundBy" field on relationships as it is set in the generic cataloger before it's presence on the relationship
   358  		opts = append(opts, cmpopts.IgnoreFields(pkg.Package{}, "FoundBy"))
   359  
   360  		// order should not matter
   361  		relationship.Sort(p.expectedRelationships)
   362  		relationship.Sort(relationships)
   363  
   364  		if diff := cmp.Diff(p.expectedRelationships, relationships, opts...); diff != "" {
   365  			t.Log("Specific Differences:\n" + r.String())
   366  
   367  			t.Errorf("unexpected relationships from parsing (-expected +actual)\n%s", diff)
   368  		}
   369  	}
   370  }
   371  
   372  func TestFileParser(t *testing.T, fixturePath string, parser generic.Parser, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) {
   373  	t.Helper()
   374  	NewCatalogTester().FromFile(t, fixturePath).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser)
   375  }
   376  
   377  func TestCataloger(t *testing.T, fixtureDir string, cataloger pkg.Cataloger, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) {
   378  	t.Helper()
   379  	NewCatalogTester().FromDirectory(t, fixtureDir).Expects(expectedPkgs, expectedRelationships).TestCataloger(t, cataloger)
   380  }
   381  
   382  func TestCatalogerFromFileSource(t *testing.T, fixturePath string, cataloger pkg.Cataloger, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) {
   383  	t.Helper()
   384  	NewCatalogTester().FromFileSource(t, fixturePath).Expects(expectedPkgs, expectedRelationships).TestCataloger(t, cataloger)
   385  }
   386  
   387  func TestFileParserWithEnv(t *testing.T, fixturePath string, parser generic.Parser, env *generic.Environment, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) {
   388  	t.Helper()
   389  
   390  	NewCatalogTester().FromFile(t, fixturePath).WithEnv(env).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser)
   391  }
   392  
   393  func AssertPackagesEqual(t *testing.T, a, b pkg.Package, userOpts ...cmp.Option) {
   394  	t.Helper()
   395  	opts := cmptest.DefaultOptions()
   396  	opts = append(opts, userOpts...)
   397  
   398  	if diff := cmp.Diff(a, b, opts...); diff != "" {
   399  		t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff)
   400  	}
   401  }
   402  
   403  func AssertPackagesEqualIgnoreLayers(t *testing.T, a, b pkg.Package, userOpts ...cmp.Option) {
   404  	t.Helper()
   405  	opts := cmptest.DefaultIgnoreLocationLayerOptions()
   406  	opts = append(opts, userOpts...)
   407  
   408  	if diff := cmp.Diff(a, b, opts...); diff != "" {
   409  		t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff)
   410  	}
   411  }
   412  
   413  func diffPackages(t *testing.T, expected []string, actual []pkg.Package, pkgStringer func(pkg.Package) string) {
   414  	t.Helper()
   415  	sort.Strings(expected)
   416  	if d := cmp.Diff(expected, stringPackages(actual, pkgStringer)); d != "" {
   417  		t.Errorf("unexpected package strings (-want, +got): %s", d)
   418  	}
   419  }
   420  
   421  func diffRelationships(t *testing.T, expected []string, actual []artifact.Relationship, pkgs []pkg.Package, pkgStringer func(pkg.Package) string) {
   422  	t.Helper()
   423  	pkgsByID := make(map[artifact.ID]pkg.Package)
   424  	for _, p := range pkgs {
   425  		pkgsByID[p.ID()] = p
   426  	}
   427  	sort.Strings(expected)
   428  	if d := cmp.Diff(expected, stringRelationships(actual, pkgsByID, pkgStringer)); d != "" {
   429  		t.Errorf("unexpected relationship strings (-want, +got): %s", d)
   430  	}
   431  }
   432  
   433  func stringRelationships(relationships []artifact.Relationship, nameLookup map[artifact.ID]pkg.Package, pkgStringer func(pkg.Package) string) []string {
   434  	var result []string
   435  	for _, r := range relationships {
   436  		var fromName, toName string
   437  		{
   438  			fromPkg, ok := nameLookup[r.From.ID()]
   439  			if !ok {
   440  				fromName = string(r.From.ID())
   441  			} else {
   442  				fromName = pkgStringer(fromPkg)
   443  			}
   444  		}
   445  
   446  		{
   447  			toPkg, ok := nameLookup[r.To.ID()]
   448  			if !ok {
   449  				toName = string(r.To.ID())
   450  			} else {
   451  				toName = pkgStringer(toPkg)
   452  			}
   453  		}
   454  
   455  		result = append(result, fromName+" ["+string(r.Type)+"] "+toName)
   456  	}
   457  	sort.Strings(result)
   458  	return result
   459  }
   460  
   461  func stringPackages(pkgs []pkg.Package, pkgStringer func(pkg.Package) string) []string {
   462  	var result []string
   463  	for _, p := range pkgs {
   464  		result = append(result, pkgStringer(p))
   465  	}
   466  	sort.Strings(result)
   467  	return result
   468  }
   469  
   470  func stringPackage(p pkg.Package) string {
   471  	locs := p.Locations.ToSlice()
   472  	var loc string
   473  	if len(locs) > 0 {
   474  		loc = p.Locations.ToSlice()[0].RealPath
   475  	}
   476  
   477  	return fmt.Sprintf("%s @ %s (%s)", p.Name, p.Version, loc)
   478  }
   479  
   480  // getFunctionName extracts the function name from a function pointer using reflection
   481  func getFunctionName(fn interface{}) string {
   482  	// get the function pointer
   483  	ptr := reflect.ValueOf(fn).Pointer()
   484  
   485  	// get the function details
   486  	funcForPC := runtime.FuncForPC(ptr)
   487  	if funcForPC == nil {
   488  		return ""
   489  	}
   490  
   491  	fullName := funcForPC.Name()
   492  
   493  	// extract just the function name from the full path
   494  	// e.g., "github.com/anchore/syft/syft/pkg/cataloger/python.parseRequirementsTxt"
   495  	//   -> "parseRequirementsTxt"
   496  	parts := strings.Split(fullName, ".")
   497  	if len(parts) > 0 {
   498  		name := parts[len(parts)-1]
   499  		// strip the -fm suffix that Go's reflection adds for methods
   500  		// e.g., "parsePackageLock-fm" -> "parsePackageLock"
   501  		return strings.TrimSuffix(name, "-fm")
   502  	}
   503  
   504  	return fullName
   505  }
   506  
   507  // getCatalogerName extracts the cataloger name from the test context or cataloger name
   508  func getCatalogerName(_ *testing.T, cataloger pkg.Cataloger) string {
   509  	// use the cataloger's name method if available
   510  	return cataloger.Name()
   511  }
   512  
   513  // getPackagePath extracts the package path from a function name
   514  // e.g., "github.com/anchore/syft/syft/pkg/cataloger/python.parseRequirementsTxt" -> "python"
   515  func getPackagePath(fn interface{}) string {
   516  	ptr := reflect.ValueOf(fn).Pointer()
   517  	funcForPC := runtime.FuncForPC(ptr)
   518  	if funcForPC == nil {
   519  		return ""
   520  	}
   521  
   522  	fullName := funcForPC.Name()
   523  
   524  	// extract package name from path
   525  	// e.g., "github.com/anchore/syft/syft/pkg/cataloger/python.parseRequirementsTxt"
   526  	//   -> "python"
   527  	if strings.Contains(fullName, "/cataloger/") {
   528  		parts := strings.Split(fullName, "/cataloger/")
   529  		if len(parts) > 1 {
   530  			// get the next segment after "/cataloger/"
   531  			remaining := parts[1]
   532  			// split by "." to get package name
   533  			pkgParts := strings.Split(remaining, ".")
   534  			if len(pkgParts) > 0 {
   535  				return pkgParts[0]
   536  			}
   537  		}
   538  	}
   539  
   540  	return ""
   541  }
   542  
   543  // getPackagePathFromCataloger extracts the package path from the caller's file path
   544  // For generic catalogers, the cataloger type is from the generic package, but we need
   545  // the package where the test is defined (e.g., rust, python, etc.)
   546  func getPackagePathFromCataloger(_ pkg.Cataloger) string {
   547  	// walk up the call stack to find the test file
   548  	// we're looking for a file in the cataloger directory structure
   549  	for i := 0; i < 10; i++ {
   550  		_, file, _, ok := runtime.Caller(i)
   551  		if !ok {
   552  			break
   553  		}
   554  
   555  		// extract package name from file path
   556  		// e.g., "/Users/.../syft/pkg/cataloger/rust/cataloger_test.go" -> "rust"
   557  		if strings.Contains(file, "/cataloger/") {
   558  			parts := strings.Split(file, "/cataloger/")
   559  			if len(parts) > 1 {
   560  				// get the next segment after "/cataloger/"
   561  				remaining := parts[1]
   562  				// split by "/" to get package name
   563  				pkgParts := strings.Split(remaining, "/")
   564  				if len(pkgParts) > 0 && pkgParts[0] != "internal" {
   565  					return pkgParts[0]
   566  				}
   567  			}
   568  		}
   569  	}
   570  
   571  	return ""
   572  }
   573  
   574  // trackParserMetadata records metadata types for a parser function
   575  func (p *CatalogTester) trackParserMetadata(t *testing.T, parser generic.Parser, pkgs []pkg.Package, relationships []artifact.Relationship) {
   576  	if p.skipTestObservations {
   577  		return
   578  	}
   579  
   580  	parserName := getFunctionName(parser)
   581  	if parserName == "" {
   582  		return
   583  	}
   584  
   585  	// try to infer package name from function path
   586  	packageName := getPackagePath(parser)
   587  	if packageName == "" {
   588  		return
   589  	}
   590  
   591  	tracker := getTracker()
   592  
   593  	// old tracking (still used by metadata discovery)
   594  	for _, pkg := range pkgs {
   595  		tracker.RecordParserPackageMetadata(packageName, parserName, pkg)
   596  	}
   597  
   598  	// new unified observations with capability tracking
   599  	tracker.RecordParserObservations(packageName, parserName, pkgs, relationships)
   600  
   601  	// ensure results are written when tests complete
   602  	t.Cleanup(func() {
   603  		_ = WriteResultsIfEnabled()
   604  	})
   605  }
   606  
   607  // trackCatalogerMetadata records metadata types for a cataloger
   608  func (p *CatalogTester) trackCatalogerMetadata(t *testing.T, cataloger pkg.Cataloger, pkgs []pkg.Package, relationships []artifact.Relationship) {
   609  	if p.skipTestObservations {
   610  		return
   611  	}
   612  
   613  	catalogerName := getCatalogerName(t, cataloger)
   614  	if catalogerName == "" {
   615  		return
   616  	}
   617  
   618  	// try to infer package name from cataloger type
   619  	packageName := getPackagePathFromCataloger(cataloger)
   620  	if packageName == "" {
   621  		return
   622  	}
   623  
   624  	tracker := getTracker()
   625  
   626  	// old tracking (still used by metadata discovery)
   627  	for _, pkg := range pkgs {
   628  		tracker.RecordCatalogerPackageMetadata(catalogerName, pkg)
   629  	}
   630  
   631  	// new unified observations with capability tracking
   632  	tracker.RecordCatalogerObservations(packageName, catalogerName, pkgs, relationships)
   633  
   634  	// ensure results are written when tests complete
   635  	t.Cleanup(func() {
   636  		_ = WriteResultsIfEnabled()
   637  	})
   638  }