Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.15.0] - 2026-10-23

### Added

- **`--max-components`** on `scan` and `scan wfp`, and **`scanoss.WithMaxComponents`** with
`scanoss.ComponentLimit`, choose how many components the server reports: a positive integer
(leading zeros allowed) or `all`. `ComponentsUpTo`, `AllComponents` and `ParseComponentLimit`
build a limit; an invalid value is rejected before anything is uploaded. The SDK sends it as
the `max_components` query parameter on every block of the upload. For a full-file match the
server reports the best N components of the project ranking plus up to three context picks
(folder, file alone, origin), each labelled in the match's `context`; a snippet block gets up
to N candidates; `all` is no limit. Unset sends nothing, so requests and results are
unchanged. The scanoss.api server must support the parameter; a server that does not will
ignore it.

## [0.14.2] - 2026-10-23

### Fixed
Expand Down Expand Up @@ -514,7 +529,8 @@ Initial release of the SCANOSS Go CLI and SDK (`scanoss`).
CycloneDX and SPDX (with `WithTool`/`WithAuthor`/`WithTimestamp` document-metadata options).
- **C shared library** (`libscanoss`) with Node.js and Python bindings.

[Unreleased]: https://github.com/scanoss/scanoss.go/compare/v0.14.2...HEAD
[Unreleased]: https://github.com/scanoss/scanoss.go/compare/v0.15.0...HEAD
[0.15.0]: https://github.com/scanoss/scanoss.go/compare/v0.14.2...v0.15.0
[0.14.2]: https://github.com/scanoss/scanoss.go/compare/v0.14.1...v0.14.2
[0.14.1]: https://github.com/scanoss/scanoss.go/compare/v0.14.0...v0.14.1
[0.14.0]: https://github.com/scanoss/scanoss.go/compare/v0.13.0...v0.14.0
Expand Down
17 changes: 16 additions & 1 deletion CLIENT_HELP.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,28 @@ Flags (persistent flags are shared with `scan wfp`): `--api-url`, `--api-key`,
`--ignore-cert-errors`, `-t, --threads` (10), `--save-wfp`, `--min-size` (bytes,
default 0), `--max-size` (0 = unlimited), `--gitignore` (true),
`--all-extensions`, `--all-folders`, `--all-hidden`, `-i, --identify`,
`-n, --ignore`, `--ranking-threshold` (-1 = off), `--skip-headers`
`-n, --ignore`, `--ranking-threshold` (-1 = off),
`--max-components` (a positive integer or `all`; unset = the server's
defaults), `--skip-headers`
(**default true**, `--skip-headers=false` to disable), `--skip-headers-limit`
(0 = no limit).

`-i, --identify` and `-n, --ignore` name a component list; see
[BOM rules](#bom-rules). `--skip-headers` applies while fingerprinting, so
`scan wfp` rejects it — the WFP it is handed was already assembled.

`--max-components` sets how many components the server reports: a positive
integer (leading zeros allowed) or `all`. It is sent as the `max_components`
query parameter on every block of the upload, so the server must support it.
For a full-file match the server reports the best N components of the project
ranking plus up to 3 context picks (folder, file alone, origin), each labelled
in the match's `context`; a snippet block gets up to N candidates; the allowed
set widens to at least N; `all` is no limit. Left unset, nothing is sent and
the server's defaults apply (2 for full-file matches plus the context picks, 1
per snippet block). A value that is not a positive integer or `all` is rejected
before anything is uploaded. `results` does not take it: it only polls a scan
that was already uploaded.

### Skipping files

Files are filtered before scanning (build dirs, vendored deps, generated/binary
Expand Down Expand Up @@ -686,6 +700,7 @@ with `--settings`. It carries BOM context and file-skip rules.
| `--format` | `raw` | `raw` / `spdx` / `cyclonedx` |
| `--chunk-size` (scan) | `1048576` | WFP upload block size (bytes) |
| `--poll-interval` (scan) | `2s` | Scan status poll cadence |
| `--max-components` (scan) | unset | Not sent; the server's default applies |
| `--min-size` / `--max-size` | `0` / `0` | Literal minimum; `0` max means unlimited |
| `--chunk-size` (decoration) | `10` | PURLs per request |
| `--workers` (decoration) | `5` | Max concurrent requests |
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,15 @@ result, err := client.Scan.Folder(ctx, "./my-project")
// resume by id: client.Scan.Wait(ctx, scanID)
```

To change how many components the server reports, pass a limit: a positive integer or all.
Unset sends nothing and the server's defaults apply; the server must support the
`max_components` parameter.

```go
limit, err := scanoss.ParseComponentLimit("5") // or scanoss.ComponentsUpTo(5), scanoss.AllComponents()
result, err := client.Scan.Folder(ctx, "./my-project", scanoss.WithMaxComponents(limit))
```

### Proxy and custom CA from the SDK

```go
Expand Down
23 changes: 23 additions & 0 deletions cmd/bomrules.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/spf13/cobra"

"github.com/scanoss/scanoss.go/pkg/sbom"
"github.com/scanoss/scanoss.go/pkg/scanoss"
"github.com/scanoss/scanoss.go/pkg/settings"
)

Expand Down Expand Up @@ -206,6 +207,28 @@ func resolveRankingThreshold(cmd *cobra.Command, s *settings.Settings) int {
return threshold
}

// addComponentFlag declares --max-components on the commands that upload a scan.
func addComponentFlag(cmd *cobra.Command) {
cmd.Flags().String("max-components", "",
`How many components the server reports: a positive integer or "all" (default: the server's own limit)`)
}

// resolveComponentLimit reads --max-components. A flag left off is an unset limit, which
// sends nothing. A flag that was passed is parsed whatever it holds, so an explicit empty value is
// refused rather than read as unset. A bad value is refused here so the command fails before it
// fingerprints or uploads.
func resolveComponentLimit(cmd *cobra.Command) (scanoss.ComponentLimit, error) {
if !cmd.Flags().Changed("max-components") {
return scanoss.ComponentLimit{}, nil
}
v, _ := cmd.Flags().GetString("max-components")
l, err := scanoss.ParseComponentLimit(v)
if err != nil {
return scanoss.ComponentLimit{}, fmt.Errorf("--max-components: %w", err)
}
return l, nil
}

// addSkipHeaderFlags declares the fingerprinting header filter flags.
//
// The filter is on by default: a licence header is boilerplate shared by every file that carries
Expand Down
204 changes: 204 additions & 0 deletions cmd/components_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// SPDX-License-Identifier: MIT
/*
* Copyright (c) 2026, SCANOSS
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

package cmd

import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)

type scanRequests struct {
mu sync.Mutex
queries []url.Values // upload (POST) requests only
}

func (s *scanRequests) server(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
s.mu.Lock()
s.queries = append(s.queries, r.URL.Query())
s.mu.Unlock()
w.WriteHeader(http.StatusAccepted)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "completed",
"result": map[string]any{"files": []any{}, "components": map[string]any{}},
})
}))
t.Cleanup(srv.Close)
return srv
}

func runScanWFPCmd(t *testing.T, srvURL string, extra ...string) error {
t.Helper()
wfpPath := filepath.Join(t.TempDir(), "in.wfp")
if err := os.WriteFile(wfpPath, []byte("file=abc,10,a.c\n"), 0o644); err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "out.json")
_, _, err := runBare(t, append([]string{
"scan", "wfp", wfpPath, "--api-url", srvURL, "--api-key", "test", "--output", out,
"--poll-interval", "10ms",
}, extra...)...)
return err
}

// runScanDirCmd drives `scan <dir>`, which fingerprints a directory and goes through
// scanpipeline.Run rather than the `scan wfp` path.
func runScanDirCmd(t *testing.T, srvURL string, extra ...string) error {
t.Helper()
dir := t.TempDir()
src := "package main\n\nfunc main() {\n\tprintln(\"hello components\")\n}\n"
if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "out.json")
_, _, err := runBare(t, append([]string{
"scan", dir, "--api-url", srvURL, "--api-key", "test", "--output", out,
"--poll-interval", "10ms",
}, extra...)...)
return err
}

func TestScanRejectsInvalidMaxComponents(t *testing.T) {
for _, bad := range []string{"0", "-1", "2.5", "ALL", "+3", "abc"} {
t.Run(bad, func(t *testing.T) {
var rec scanRequests
srv := rec.server(t)
err := runScanWFPCmd(t, srv.URL, "--max-components="+bad)
if err == nil || !strings.Contains(err.Error(), "--max-components") {
t.Fatalf("error = %v, want one naming --max-components", err)
}
if n := len(rec.queries); n != 0 {
t.Errorf("server saw %d requests, want 0: the value must be refused before any upload", n)
}
})
}
}

func TestScanSendsMaxComponents(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
}{
{"number", []string{"--max-components", "7"}, "7"},
{"all", []string{"--max-components", "all"}, "all"},
{"unset", nil, ""},
} {
t.Run(tc.name, func(t *testing.T) {
var rec scanRequests
srv := rec.server(t)
if err := runScanWFPCmd(t, srv.URL, tc.args...); err != nil {
t.Fatal(err)
}
if len(rec.queries) == 0 {
t.Fatal("server saw no upload")
}
for _, q := range rec.queries {
if tc.want == "" {
if len(q) != 0 {
t.Errorf("an unset flag sent a query: %v", q)
}
continue
}
if got := q["max_components"]; len(got) != 1 || got[0] != tc.want {
t.Errorf("upload query %v, want max_components=%q", q, tc.want)
}
}
})
}
}

func TestScanDirRejectsInvalidMaxComponents(t *testing.T) {
for _, bad := range []string{"0", "-1", "2.5", "ALL", "+3", "abc", ""} {
t.Run("value="+bad, func(t *testing.T) {
var rec scanRequests
srv := rec.server(t)
err := runScanDirCmd(t, srv.URL, "--max-components="+bad)
if err == nil || !strings.Contains(err.Error(), "--max-components") {
t.Fatalf("error = %v, want one naming --max-components", err)
}
if n := len(rec.queries); n != 0 {
t.Errorf("server saw %d requests, want 0: the value must be refused before any upload", n)
}
})
}
}

func TestScanDirSendsMaxComponents(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
}{
{"number", []string{"--max-components", "7"}, "7"},
{"all", []string{"--max-components", "all"}, "all"},
{"unset", nil, ""},
} {
t.Run(tc.name, func(t *testing.T) {
var rec scanRequests
srv := rec.server(t)
if err := runScanDirCmd(t, srv.URL, tc.args...); err != nil {
t.Fatal(err)
}
if len(rec.queries) == 0 {
t.Fatal("server saw no upload")
}
for _, q := range rec.queries {
if tc.want == "" {
if len(q) != 0 {
t.Errorf("an unset flag sent a query: %v", q)
}
continue
}
if got := q["max_components"]; len(got) != 1 || got[0] != tc.want {
t.Errorf("upload query %v, want max_components=%q", q, tc.want)
}
}
})
}
}

// An explicitly empty value is a mistake (an unset shell variable, say), not an unset limit.
func TestScanWFPRejectsEmptyMaxComponents(t *testing.T) {
var rec scanRequests
srv := rec.server(t)
err := runScanWFPCmd(t, srv.URL, "--max-components", "")
if err == nil || !strings.Contains(err.Error(), "--max-components") {
t.Fatalf("error = %v, want one naming --max-components", err)
}
if n := len(rec.queries); n != 0 {
t.Errorf("server saw %d requests, want 0", n)
}
}
19 changes: 15 additions & 4 deletions cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ func init() {
addBOMRuleFlags(scanWFPCmd)
addRankingFlag(scanCmd)
addRankingFlag(scanWFPCmd)
addComponentFlag(scanCmd)
addComponentFlag(scanWFPCmd)
addSkipHeaderFlags(scanWFPCmd) // declared so the flag is rejected with a reason, see runScanWFP
}

Expand All @@ -393,6 +395,10 @@ func runScan(cmd *cobra.Command, args []string) error {
if err := validateOutputFormat(cmd); err != nil {
return err
}
components, err := resolveComponentLimit(cmd)
if err != nil {
return err
}
layers, err := scanLayers(cmd)
if err != nil {
return err
Expand Down Expand Up @@ -474,7 +480,7 @@ func runScan(cmd *cobra.Command, args []string) error {
Threads: threads,
ScanFilters: collectOpts,
DependencyFilters: depOpts,
ScanOptions: scanTuning(cmd, scanSettings, prog),
ScanOptions: scanTuning(cmd, scanSettings, prog, components),
WFPOptions: fingerprintOptions(skipHeaders, skipHeadersLimit),
WFPWriter: wfpWriterOrNil(wfpOut),
OnProgress: prog.layer,
Expand Down Expand Up @@ -522,6 +528,10 @@ func runScanWFP(cmd *cobra.Command, args []string) error {
if err := validateOutputFormat(cmd); err != nil {
return err
}
components, err := resolveComponentLimit(cmd)
if err != nil {
return err
}
// A bare WFP has no source tree, so declared dependencies cannot be sourced. Rejecting deps
// beats warning and carrying on: the output is an inventory the caller keeps, and one missing
// the layer they asked for should not leave with a success code.
Expand Down Expand Up @@ -581,7 +591,7 @@ func runScanWFP(cmd *cobra.Command, args []string) error {
rep := scanpipeline.NewReporter(prog.layer)

var bom bomReport
scanOpts := append(scanTuning(cmd, scanSettings, prog), scanoss.WithScanReporter(rep), bom.option())
scanOpts := append(scanTuning(cmd, scanSettings, prog, components), scanoss.WithScanReporter(rep), bom.option())
res, err := client.Scan.WFP(ctx, wfp, scanOpts...)
if err != nil {
return renderAPIError(fmt.Errorf("scan failed: %w", err))
Expand Down Expand Up @@ -611,9 +621,9 @@ func buildScanClient(cmd *cobra.Command) (*scanoss.Client, error) {
return scanoss.New(cfg)
}

// scanTuning builds the per-scan options — chunk size, poll interval, the BOM rules, the ranking
// scanTuning builds the per-scan options — chunk size, poll interval, the component limit, the BOM rules, the ranking
// threshold and the scan-id notice — from the flags and settings.
func scanTuning(cmd *cobra.Command, scanSettings *settings.Settings, prog *scanProgress) []scanoss.ScanOption {
func scanTuning(cmd *cobra.Command, scanSettings *settings.Settings, prog *scanProgress, components scanoss.ComponentLimit) []scanoss.ScanOption {
chunkSize, _ := cmd.Flags().GetInt("chunk-size")
pollInterval, _ := cmd.Flags().GetDuration("poll-interval")
if chunkSize < 1024 {
Expand All @@ -624,6 +634,7 @@ func scanTuning(cmd *cobra.Command, scanSettings *settings.Settings, prog *scanP
scanOpts := []scanoss.ScanOption{
scanoss.WithChunkBytes(chunkSize),
scanoss.WithPollInterval(pollInterval),
scanoss.WithMaxComponents(components),
scanoss.WithScanIDNotify(func(id string) {
prog.writeLine("") // separate the scan-id block from the filter/skip notices above
prog.writeLine(infoLine("Scan id: %s", id))
Expand Down
Loading
Loading