detect

package
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 17, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package detect finds the license files in a tree and identifies what they are.

It is the shared implementation of a step that used to exist in three diverging copies: in melange's build-time license check, in the evaluation of an upstream release, and in the License Oracle. They differ in what they do with the answer, not in how the answer is reached.

Discovery is scoping, identification is a conclusion

Discovery decides which files are worth reading, by filename and by where they sit. It is a cost and attribution heuristic: a depth cap keeps a sweep affordable, and an ignored-directory list keeps a dependency's license from being read as the project's own. Neither is a claim about licensing, which is why the license-content hash in contenthash does not use them - two copies differing only in a subdirectory's license must not hash alike.

Identification is separate and comes second. Classifying a COPYING file against a corpus of license texts is an act of conclusion, so it is reported with the confidence it was reached at, and a caller can re-run it against retained text later without re-reading the tree.

Input

Every entry point takes an fs.FS. A live build workspace, an unpacked package, an expanded archive and an in-memory fixture are the same input, so there is no separate code path for any of them and no test needs a filesystem.

Failure is per file

An unreadable file records its own error and does not discard the results for the others. A caller can therefore tell a tree with no license evidence from a tree it could not finish reading, and decide for itself which of those should fail its own operation.

Index

Examples

Constants

View Source
const (
	// MaxNoticeBytes is how much of a source file is read when looking for a
	// license notice. The notice sits at the top of the file or not at all.
	MaxNoticeBytes = 2 << 10
	// MaxNoticeFiles bounds the sample. A project that uses a notice repeats it
	// in every file, so a spread of a couple of dozen establishes it; the count
	// exists to catch a tree that is not uniform, not to be thorough.
	MaxNoticeFiles = 24
)
View Source
const ConfidenceThreshold = 0.9

ConfidenceThreshold is the classifier confidence at or above which a license-text match is treated as settled. Below it the match is reported with its confidence and counts as unidentified, which is what sends a component to be read by something that can weigh the whole document.

View Source
const MaxTextBytes = 128 << 10

MaxTextBytes is the default bound on how much of one license text is retained. License texts are kilobytes; a file far past this is not a license, and retaining it unbounded would let a source tree decide how much memory the caller holds.

View Source
const NoAssertion = "NOASSERTION"

NoAssertion is the SPDX sentinel for "no claim is being made", reported both when no license text was found and when a classification was inconclusive.

View Source
const RootWeightBonus = 0.5

RootWeightBonus is added to a file in the component root, so that the project's own license outranks every file below it however the two are named.

Variables

This section is empty.

Functions

func GrantFor

func GrantFor(r spdx.Resolution, grants []Grant) (expr string, matched int)

GrantFor reports the grant resolving a GPL-family identifier, and how many distinct grants the notices stated for it.

A grant only counts when it names the same base license that was detected: a tree whose notices say GPL-3.0 does not resolve the suffix of an LGPL-3.0 license file, and quietly letting it would turn a real disagreement into a confident wrong answer.

A count above one means the tree stated both grants for the same license. That is a finding rather than a tie to break, and the caller escalates.

Conclude calls this to settle the component's own license. It is exported for the caller resolving a grant itself - one composing an expression from several identifiers, say, where which grant applies to which is the caller's question rather than this package's.

func IsLicenseFile

func IsLicenseFile(name string) (bool, float64)

IsLicenseFile reports whether a name is a license file, and how canonical the name is.

The test is on the name alone, deliberately. Where a file sits is a separate question that ClassifyExclusion answers, because the two are needed separately: the license-content hash selects files by name with no regard to depth, while discovery wants both.

Where several patterns match, the highest weight wins, so the answer does not depend on map iteration order.

Example

IsLicenseFile answers the filename question on its own, which is what the license-content hash needs: no depth rule, no directory rule.

package main

import (
	"fmt"

	"chainguard.dev/license/detect"
)

func main() {
	for _, name := range []string{"LICENSE", "COPYING.md", "LICENSE-MIT", "license.go", "README.md"} {
		is, weight := detect.IsLicenseFile(name)
		fmt.Printf("%-12s %v %.2f\n", name, is, weight)
	}
}
Output:
LICENSE      true 1.00
COPYING.md   true 0.85
LICENSE-MIT  true 0.70
license.go   false 0.00
README.md    false 0.00

func IsProseFile

func IsProseFile(name string) bool

IsProseFile reports whether a name is a file upstreams state licensing in prose in. It is exported because a caller deciding what to retain from a fetched artifact needs the same rule that reads a source tree.

func Supplement

func Supplement() map[string][]byte

Supplement returns canonical license texts keyed by SPDX identifier, for loading into a classifier that would otherwise misidentify them.

Types

type Candidate

type Candidate struct {
	// Path is the file's path within the scanned filesystem, slash-separated.
	Path string
	// Weight scores how likely the file is to hold the component's own license.
	// Files in the root carry RootWeightBonus on top of their filename weight.
	Weight float64
	// Excluded says why the file is not evidence about this component, and is
	// empty for a file that is. ScopeProject never returns a file excluded as
	// vendored or build-output, since it does not descend into those trees.
	Excluded ExclusionReason
}

Candidate is a discovered license file, before anything has been read.

type Classifier

type Classifier interface {
	// Identify classifies the contents of one file, returning one Match per
	// distinct license recognized in it, in the classifier's own order.
	Identify(fsys fs.FS, path string) ([]Match, error)
	// IdentifyHeader matches a license notice in source text.
	IdentifyHeader(data []byte) []HeaderMatch
}

Classifier identifies license texts and license notices.

func DefaultClassifier

func DefaultClassifier() (Classifier, error)

DefaultClassifier returns the classifier this package uses when a request names none, built once per process.

It is exported because parsing the corpus costs a third of a second, and a caller that needs a classifier of its own - to read notices alongside license files, say - should not pay that per component. It is also what keeps one classifier answering every question in a run, which is what stops a supplemented corpus from being silently bypassed by half the callers.

func NewClassifier

func NewClassifier() (Classifier, error)

NewClassifier returns a classifier over the licenseclassifier corpus together with Supplement, which is the combination this module treats as authoritative.

Example

A classifier can be built explicitly when one classifier should answer every question in a run, which is also what keeps a supplemented corpus from being silently bypassed.

package main

import (
	"fmt"
	"testing/fstest"

	"chainguard.dev/license/detect"
)

// mit is the MIT license text, which is what a classifier needs to see in order
// to recognize it. Abbreviating it would make the examples wrong rather than
// shorter.
const mit = `MIT License

Copyright (c) 2026 Example

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.
`

func main() {
	c, err := detect.NewClassifier()
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	matches, err := c.Identify(fstest.MapFS{"LICENSE": &fstest.MapFile{Data: []byte(mit)}}, "LICENSE")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(matches[0].Name, matches[0].Confidence >= detect.ConfidenceThreshold)
}
Output:
MIT true

func NewClassifierWithSupplement

func NewClassifierWithSupplement(extra map[string][]byte) (Classifier, error)

NewClassifierWithSupplement returns a classifier that also knows the license texts in extra, keyed by SPDX identifier.

The bundled corpus covers a minority of the SPDX license list, and a license missing from it is not reported as unrecognized: it is reported as the nearest text the corpus does contain, at full confidence. Supplying the missing texts is the only fix for that, since no amount of reasoning about a wrong answer recovers the right one.

A caller that must reproduce the bundled corpus exactly, with no additions, passes nil.

type Conclusion

type Conclusion struct {
	// Expression is the SPDX expression the evidence settles on, and is empty
	// exactly when Reason is not ReasonSettled.
	Expression string
	// Reason says why no expression was reached.
	Reason Reason
	// Identifiers are the distinct SPDX identifiers found in scope, sorted.
	// It is populated whether or not an expression was reached, so a caller
	// reporting an ambiguity can say what the licenses were.
	Identifiers []string
	// GrantBasis says where a GPL-family version grant came from, and is empty
	// when no license involved has one.
	GrantBasis GrantBasis
	// Unrecognized are the in-scope files holding license text that nothing
	// identified, in discovery order. A caller minting a LicenseRef names it
	// from the text of the first of these, which UnrecognizedText returns.
	Unrecognized []File
	// Files are the in-scope files the conclusion was drawn from.
	Files []File
}

Conclusion is the license a component's own license files resolve to.

func (Conclusion) UnrecognizedText

func (c Conclusion) UnrecognizedText() (string, error)

UnrecognizedText returns the text a LicenseRef is minted from, which is the text of the strongest file nothing identified.

It reports an error rather than an empty string when the conclusion holds unrecognized text that was not retained, because the request did not ask for it. A ref cannot be derived from text nobody kept, and returning nothing would name the license the empty string hashes to - which reads downstream as no license found, the worst available answer for the proprietary license this path exists to catch. The file's path and digest are still in Unrecognized, so a caller holding the tree can read the text and try again.

Example
package main

import (
	"fmt"

	"chainguard.dev/license/detect"
)

func main() {
	// A conclusion reached without Request.RetainText knows a license text was
	// there but does not hold it, so it cannot be named.
	withoutText := detect.Conclusion{
		Reason:       detect.ReasonUnrecognized,
		Unrecognized: []detect.File{{Path: "LICENSE.txt", SizeBytes: 4096}},
	}
	if _, err := withoutText.UnrecognizedText(); err != nil {
		fmt.Println("not named:", err)
	}

	withText := detect.Conclusion{
		Reason:       detect.ReasonUnrecognized,
		Unrecognized: []detect.File{{Path: "LICENSE.txt", SizeBytes: 26, Text: "Bespoke terms, all rights."}},
	}
	text, err := withText.UnrecognizedText()
	if err != nil {
		fmt.Println("unexpected:", err)
		return
	}
	fmt.Println("named from:", text)

}
Output:
not named: license text at "LICENSE.txt" was not retained: naming it needs Request.RetainText
named from: Bespoke terms, all rights.

type Declaration

type Declaration struct {
	// License is the declared SPDX expression, exactly as declared. It is not
	// canonicalized here: reporting what was written is what lets a
	// disagreement be attributed to the declaration rather than to a rewrite of
	// it. Run spdx.Canonicalize to compare it against detection.
	License string `json:"license"`
	// Path names the license file the declaration describes, when it describes
	// one. A declaration with no path speaks for the component as a whole.
	Path string `json:"path,omitempty"`
	// Override records a classification the declaration knowingly disagrees
	// with, so that a difference somebody has already reviewed stops being
	// reported as new. It holds the identifier the classifier is expected to
	// produce, and stops applying the moment the classifier produces a
	// different one.
	Override string `json:"override,omitempty"`
}

Declaration is a license the caller already believes applies, taken from a build configuration or a manifest.

It is ecosystem-neutral on purpose. melange reads it out of a package's copyright block, an npm consumer out of package.json, and another consumer out of whatever produced its evidence; none of that shape reaches this package, which knows only that somebody claimed a license and, sometimes, which file they claimed it for.

type Discovery

type Discovery struct {
	// Candidates are the discovered license files, ordered by descending
	// weight with ties broken by path.
	Candidates []Candidate
	// Unreadable are the paths the scan could not read or descend into.
	//
	// They are reported rather than fatal, because a tree with one unreadable
	// directory still has evidence in the rest of it - but they are reported
	// rather than ignored, because a component that appears to have no license
	// and a component whose license directory could not be read mean opposite
	// things.
	Unreadable []string
}

Discovery is what a scan of a filesystem found.

func Find

func Find(fsys fs.FS, scope Scope) (Discovery, error)

Find discovers the license files on a filesystem, ordered by descending weight with ties broken by path so the order is deterministic.

A symlink is followed when it resolves within fsys, since repositories commonly symlink LICENSE at the root to the real text. A link escaping the filesystem fails to resolve and is skipped rather than read, which is why callers scanning a directory should pass an os.Root filesystem.

Example

A vendored license belongs to the component that vendored it, so it is reported with a reason rather than counted as the project's own.

package main

import (
	"fmt"
	"testing/fstest"

	"chainguard.dev/license/detect"
)

// mit is the MIT license text, which is what a classifier needs to see in order
// to recognize it. Abbreviating it would make the examples wrong rather than
// shorter.
const mit = `MIT License

Copyright (c) 2026 Example

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.
`

func main() {
	fsys := fstest.MapFS{
		"LICENSE":                 &fstest.MapFile{Data: []byte(mit)},
		"vendor/dep/LICENSE":      &fstest.MapFile{Data: []byte(mit)},
		"testdata/corpus/LICENSE": &fstest.MapFile{Data: []byte(mit)},
	}

	found, err := detect.Find(fsys, detect.ScopeTree)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	for _, f := range found.Candidates {
		fmt.Printf("%-25s %q\n", f.Path, f.Excluded)
	}
}
Output:
LICENSE                   ""
testdata/corpus/LICENSE   "test-fixture"
vendor/dep/LICENSE        "vendored"

type ExclusionReason

type ExclusionReason string

ExclusionReason explains why a discovered license file is not evidence about the licensing of the component it was found in.

Exclusions are recorded rather than dropped. A license file under docs/ or examples/ is occasionally the project's real license, so the decision stays visible to a reader of the evidence instead of silently removing a file from it.

const (
	// ExclusionNone marks a file that describes the component itself.
	ExclusionNone ExclusionReason = ""
	// ExclusionVendored marks a file that describes bundled third-party code.
	// These files are still load-bearing for dependency collection, which maps
	// them onto the components they belong to.
	ExclusionVendored ExclusionReason = "vendored"
	// ExclusionSupplementary marks a file that accompanies a license without
	// being one: a patent grant, an attribution notice, an authors list. These
	// are worth collecting as context, but treating one as an unreadable
	// license text would report a gap where none exists.
	ExclusionSupplementary ExclusionReason = "supplementary"
	// ExclusionTestFixture marks a file that describes test or sample data.
	// Fixture corpora routinely carry licenses unrelated to the project, and
	// often deliberately exotic ones, so they must not reach the component's
	// own license expression.
	ExclusionTestFixture ExclusionReason = "test-fixture"
	// ExclusionBuildOutput marks a file under the tree a build installed into
	// rather than the tree it was built from. What a build installs is a mix of
	// the component's own artifacts and whatever its dependencies brought with
	// them, so nothing there speaks for the component on its own.
	ExclusionBuildOutput ExclusionReason = "build-output"
)

func ClassifyDirExclusion

func ClassifyDirExclusion(p string) ExclusionReason

ClassifyDirExclusion reports whether a path lies outside the component's own source, from its directory segments alone.

Enumerating component identities uses this rather than ClassifyExclusion: a manifest is not a license file, so the filename rules do not apply to it, but the location rules do. A go.sum under tests/ belongs to a fixture corpus, and reading it invents dependencies the package does not have - in bat's tree that file is ANSI-rendered highlighter output, so the "modules" it yields carry escape codes in their purls. A go.sum under melange-out/ came from the build's own output and describes whatever was installed there, not the source.

func ClassifyExclusion

func ClassifyExclusion(p string) ExclusionReason

ClassifyExclusion reports whether a license-file path describes the component itself, and if not, why.

Callers classifying the contents of a fetched artifact use it too, so a published module and a package's source tree are scoped by identical rules.

Example

ClassifyExclusion says whether a discovered file describes the component itself, and if not, why.

package main

import (
	"fmt"

	"chainguard.dev/license/detect"
)

func main() {
	for _, path := range []string{"LICENSE", "docs/LICENSE", "vendor/x/LICENSE", "PATENTS", "melange-out/pkg/LICENSE"} {
		fmt.Printf("%-24s %q\n", path, detect.ClassifyExclusion(path))
	}
}
Output:
LICENSE                  ""
docs/LICENSE             ""
vendor/x/LICENSE         "vendored"
PATENTS                  "supplementary"
melange-out/pkg/LICENSE  "build-output"

type File

type File struct {
	// Path is the file's path within the scanned filesystem.
	Path string `json:"path"`
	// Weight is the filename-based relevance from discovery, carried so that
	// whatever weighs candidates later weighs them as discovery did.
	Weight float64 `json:"weight"`
	// Excluded says why the file is not evidence about this component, and is
	// empty for a file that is.
	Excluded ExclusionReason `json:"excluded,omitempty"`
	// License is the classifier's own name for the strongest match, reported
	// verbatim, and NoAssertion when nothing matched. Normalize it through the
	// spdx package before comparing or publishing it.
	License string `json:"license"`
	// Confidence is the classifier's confidence in License.
	Confidence float64 `json:"confidence"`
	// AdditionalLicenses are the other licenses the classifier recognized in
	// the same file, strongest first.
	//
	// One file can carry two license texts - a LICENSE that appends a bundled
	// dependency's terms is the common shape - and they are two licenses. Only
	// reporting the strongest would settle such a component on one license and
	// hide the other, which is the direction of error that tells a consumer
	// they may ignore obligations they have.
	AdditionalLicenses []Match `json:"additional_licenses,omitempty"`
	// Declared is the license the caller declared for this file, when they
	// declared one, and Override the classification that declaration knowingly
	// disagrees with.
	Declared string `json:"declared,omitempty"`
	Override string `json:"override,omitempty"`
	// SizeBytes and Digest identify the whole file regardless of what Text
	// holds. The digest normalizes line endings; see contenthash for what that
	// means and why.
	SizeBytes int64  `json:"size_bytes"`
	Digest    string `json:"digest"`
	// Text is the retained license text, present only when the request asked
	// for it, and Truncated records that it is only the beginning of the file
	// so that a later non-match can be attributed to the bound rather than to
	// the text.
	Text      string `json:"text,omitempty"`
	Truncated bool   `json:"truncated,omitempty"`
	// Err records why this file could not be read or classified. The rest of
	// the result stands: one unreadable file is not a reason to discard the
	// evidence the others carry.
	Err string `json:"error,omitempty"`
}

File is one discovered license file and what it was concluded to be.

func (File) Confident

func (f File) Confident() bool

Confident reports whether the file both classified and did so at or above ConfidenceThreshold.

func (File) Identified

func (f File) Identified() bool

Identified reports whether the file classified to a license SPDX can name.

func (File) Identifier

func (f File) Identifier() string

Identifier is the SPDX identifier of the file's strongest match, or "" for a file nothing identified.

A GPL-family match whose version grant is still undecided contributes its base identifier, so that two files carrying the same license do not read as two different licenses and raise a relationship ambiguity that is not there.

func (File) Identifiers

func (f File) Identifiers() []string

Identifiers are every SPDX identifier the file contributes to the component's license set, from the matches classified at or above ConfidenceThreshold, sorted.

Empty when nothing in the file classified confidently, which is what distinguishes a file that named a license from one that only held text.

func (File) InScope

func (f File) InScope() bool

InScope reports whether the file is evidence about the component's own licensing.

func (File) Resolution

func (f File) Resolution() spdx.Resolution

Resolution maps the classifier's name onto SPDX.

Derived rather than stored, so there is one source of truth: a stored copy could be left unset by a caller building the struct directly, and the file would then read as unidentified.

type Grant

type Grant struct {
	// Expression is the SPDX expression the notice states, suffix included.
	Expression string `json:"expression"`
	// Source is "spdx-tag" for a machine-readable SPDX-License-Identifier, or
	// "header" for a matched notice. The tag is exact; the header is a match.
	Source string `json:"source"`
	// Path locates the grant, and Files says how many sampled files agreed.
	Path  string `json:"path"`
	Files int    `json:"files"`
}

Grant is a version grant stated in a source notice.

func Grants

func Grants(c Classifier, notices []Notice) []Grant

Grants derives the version grants stated across a sample of source notices.

A single entry is the ordinary case: a project states its grant the same way in every file. Two or more mean the tree is genuinely mixed, which is a finding rather than noise, and the caller escalates rather than picking.

Both mechanisms are needed because they cover disjoint conventions: modern projects carry an SPDX-License-Identifier tag, which the classifier does not recognize at all, while older ones carry a prose notice, which the classifier matches well but reports under the bare license name with the grant hidden in the variant.

Example

Notices and Grants settle the one thing a license text cannot state: whether a GPL-family license grants later versions.

package main

import (
	"fmt"
	"testing/fstest"

	"chainguard.dev/license/detect"
)

func main() {
	fsys := fstest.MapFS{
		"main.c": &fstest.MapFile{Data: []byte("/* SPDX-License-Identifier: GPL-2.0-or-later */\nint main(void){return 0;}\n")},
	}

	notices, sampled := detect.Notices(fsys)
	for _, g := range detect.Grants(nil, notices) {
		fmt.Printf("%s from %s (%d of %d files)\n", g.Expression, g.Source, g.Files, sampled)
	}
}
Output:
GPL-2.0-or-later from spdx-tag (1 of 1 files)

type GrantBasis

type GrantBasis string

GrantBasis records how a version grant was established. It exists because a license text cannot express one, so a reader deserves to know where the suffix came from.

const (
	// GrantNotApplicable means no license involved has a version grant.
	GrantNotApplicable GrantBasis = ""
	// GrantFromNotice means a notice in the component's own source stated it.
	GrantFromNotice GrantBasis = "notice"
	// GrantAbsent means the notices were read and none granted later versions,
	// which is itself the answer: the grant has to be explicit.
	GrantAbsent GrantBasis = "absent"
	// GrantUnknown means no source was read, so the suffix rests on the
	// convention that a grant must be explicit rather than on evidence.
	GrantUnknown GrantBasis = "unknown"
	// GrantMixed means the tree stated both grants for one license.
	GrantMixed GrantBasis = "mixed"
)

type GrantEvidence

type GrantEvidence struct {
	// Grants are the distinct grants stated, most-agreed first.
	Grants []Grant
	// Sampled is how many source files the grants were read from, which is
	// what Notices returns rather than how many it found. Zero means nobody
	// looked, which is a weaker claim than looking and finding none, so
	// counting a file that failed to open would settle a grant on no evidence.
	Sampled int
}

GrantEvidence is what a tree's notices said about version grants.

type HeaderMatch

type HeaderMatch struct {
	Name       string
	Variant    string
	Confidence float64
}

HeaderMatch is a license notice matched in source text.

Notices are matched separately from license files because they answer a different question. A license file says which license; a notice says how the author applied it - and for the GPL family that is the only place the -only/-or-later grant appears, since SPDX ships identical text for both. Variant identifies which wording matched, which is what distinguishes a later-version grant from a bare-version one.

type Match

type Match struct {
	// Name is the classifier's own name for the license, reported verbatim.
	// It is frequently not an SPDX identifier - the classifier emits names
	// that were never SPDX and names SPDX has deprecated - so nothing
	// downstream should compare or publish it without normalizing it through
	// the spdx package first.
	Name       string
	Confidence float64
}

Match is one license the classifier recognized in a file.

type Notice

type Notice struct {
	Path string `json:"path"`
	// Text is the first MaxNoticeBytes of the file.
	Text      string `json:"text"`
	Truncated bool   `json:"truncated,omitempty"`
}

Notice is the leading bytes of one source file.

It exists because a license text cannot express the version grant a GPL-family license depends on: SPDX ships identical text for the -only and -or-later forms, and that text states "any later version" three times in its own body, so no amount of reading the license settles which grant the author made. Only the notice at the top of their own source does.

func Notices

func Notices(fsys fs.FS) (notices []Notice, sampled int)

Notices retains a deterministic sample of source-file heads, and reports how many of them were read.

The count matters as much as the sample: it is what distinguishes "the notices were read and none granted later versions", which is itself the answer, from "nobody looked", which is not. So it counts files actually read, not candidates found. A tree whose sources cannot be opened yields zero, and the caller reports an unknown grant rather than an absent one.

The sample is chosen by a fixed stride over sorted paths rather than by taking the first few, so a subtree licensed differently from the root is represented instead of being alphabetically excluded, and so the same tree always yields the same sample - evidence is content-addressed downstream, and a sample that varied between runs would defeat that.

License files are deliberately excluded, for the reason above: sampling COPYING would report a later-version grant for every GPL project in existence.

type ProseHint

type ProseHint struct {
	Path    string `json:"path"`
	Line    int    `json:"line"`
	Excerpt string `json:"excerpt"`
}

ProseHint is a license mention found in a prose file.

func Prose

func Prose(fsys fs.FS) []ProseHint

Prose scans the top-level README, COPYRIGHT, NOTICE and AUTHORS files for lines that mention a license.

It exists because a license file is not the only place licensing is written down. Upstreams frequently state their license only in prose, and prose is also where a choice between several licenses is usually spelled out: whole ecosystems have no manifest field to declare one in, so for a Go module shipping two license texts the README is the only thing that says whether they compose with AND or with OR.

Only the top level is read. A license statement in a subdirectory's README is describing that subdirectory, and a component's own licensing is stated where a reader would look for it.

Example

Prose is where upstreams that ship no license file state their licensing, and where a choice between several licenses is usually spelled out.

package main

import (
	"fmt"
	"testing/fstest"

	"chainguard.dev/license/detect"
)

func main() {
	fsys := fstest.MapFS{
		"README.md": &fstest.MapFile{Data: []byte("# Example\n\nDual-licensed under MIT or Apache-2.0 at your option.\n")},
	}

	for _, hint := range detect.Prose(fsys) {
		fmt.Printf("%s:%d %s\n", hint.Path, hint.Line, hint.Excerpt)
	}
}
Output:
README.md:3 Dual-licensed under MIT or Apache-2.0 at your option.

type Reason

type Reason string

Reason names why a conclusion carries no expression. It is a closed set, so consumers match on it rather than parsing prose.

const (
	// ReasonSettled means the conclusion carries an expression.
	ReasonSettled Reason = ""
	// ReasonNoLicenseText means the component ships no license text in scope.
	// Nothing was read, so there is nothing for a deeper look to read either --
	// this is a coverage gap rather than an ambiguity.
	ReasonNoLicenseText Reason = "no-license-text"
	// ReasonUnrecognized means license text is present but nothing identified
	// it. A caller that has to record the license anyway mints a LicenseRef
	// from the text, so a proprietary EULA is recorded rather than dropped.
	ReasonUnrecognized Reason = "unrecognized-license-text"
	// ReasonSeveralLicenses means several licenses were identified with nothing
	// authoritative saying how they compose. Deterministic detection
	// structurally cannot decide between AND and OR here, and guessing wrong in
	// the OR direction would tell a consumer they may ignore obligations they
	// have.
	ReasonSeveralLicenses Reason = "several-licenses"
	// ReasonGrantUndecided means one license is identified but the tree states
	// both version grants for it, so which one the author made is a finding
	// rather than a tie to break.
	ReasonGrantUndecided Reason = "grant-undecided"
)

type Request

type Request struct {
	// FS is the tree to read. Callers scanning a directory should pass an
	// os.Root filesystem, so a symlink escaping the tree fails to resolve
	// rather than reading the host filesystem.
	FS fs.FS
	// Scope bounds discovery. The zero value, ScopeProject, looks for the
	// component's own license.
	Scope Scope
	// Declared are the licenses the caller already believes apply. A
	// declaration naming a path that discovery did not find is still read and
	// classified, since a component that keeps its license somewhere
	// unconventional is exactly the case a declaration exists to record.
	Declared []Declaration
	// RetainText keeps each classified text in the result, bounded by
	// MaxTextBytes, for a caller that has to persist evidence or classify it
	// again later. It is off by default: a build-time check has the tree in
	// front of it and needs no copy.
	//
	// Naming an unrecognized license needs it. A LicenseRef is a digest of the
	// license text, so Conclusion.UnrecognizedText fails without it rather than
	// naming the component something that reads as unlicensed.
	RetainText bool
	// Classifier identifies the texts. A nil Classifier uses a shared default
	// built once per process from the licenseclassifier corpus and Supplement.
	Classifier Classifier
}

Request is what to inspect.

type Result

type Result struct {
	// Files are the discovered license files, ordered by descending discovery
	// weight with ties broken by path.
	Files []File `json:"files"`
	// Unreadable are the paths the scan could not read or descend into, so that
	// a tree with no license evidence stays distinguishable from one that could
	// not be read to the end.
	Unreadable []string `json:"unreadable,omitempty"`
	// Notes carry caveats about the completeness of the result: texts truncated
	// at the bound, declarations naming a file that is not there. They describe
	// the collection, never the licensing.
	Notes []string `json:"notes,omitempty"`
}

Result is what a tree contained.

func Detect

func Detect(ctx context.Context, req Request) (*Result, error)

Detect discovers the license files in a tree and classifies them.

An invalid request returns an error, as does a failure to walk the tree at all. Everything else is reported per file: a file that could not be read or classified carries its own Err and the rest of the result stands, so a caller can tell a tree with no license evidence from one it could not finish reading.

Example

Detect and Conclude are the two calls a consumer makes: find and classify the license files, then ask what they settle.

package main

import (
	"context"
	"fmt"
	"testing/fstest"

	"chainguard.dev/license/detect"
)

// mit is the MIT license text, which is what a classifier needs to see in order
// to recognize it. Abbreviating it would make the examples wrong rather than
// shorter.
const mit = `MIT License

Copyright (c) 2026 Example

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.
`

func main() {
	fsys := fstest.MapFS{
		"LICENSE":            &fstest.MapFile{Data: []byte(mit)},
		"vendor/dep/LICENSE": &fstest.MapFile{Data: []byte("Apache License Version 2.0")},
	}

	res, err := detect.Detect(context.Background(), detect.Request{FS: fsys})
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(res.Conclude(detect.GrantEvidence{}).Expression)
}
Output:
MIT

func (*Result) Conclude

func (r *Result) Conclude(grants GrantEvidence) Conclusion

Conclude resolves the component's own license from the files that were classified.

Only files in scope count: a vendored dependency's license and a fixture corpus's exotic license say nothing about this component. Of those, a license file in the root wins outright - when the root holds any license file, files below it are not considered at all, so a stray license deeper in the tree cannot make the project's own licensing look ambiguous.

grants resolves the GPL family's -only versus -or-later suffix, which no license text can express. Where the notices state no grant, the conclusion is the -only form, because that grant has to be explicit; whether that reading rests on notices actually read or on nobody having looked is the difference between GrantAbsent and GrantUnknown, and the caller can tell which it got.

func (*Result) InScope

func (r *Result) InScope() []File

InScope returns the files that are evidence about the component itself.

type Scope

type Scope int

Scope bounds which files discovery considers.

const (
	// ScopeProject looks for the component's own license: the root and one
	// directory below it, with the trees belonging to other components skipped.
	// It is what a build-time check and an upstream evaluation want, and it is
	// bounded so that scanning a large tree stays cheap.
	ScopeProject Scope = iota
	// ScopeTree walks the whole filesystem, vendored subdirectories included,
	// and records why each file is out of the component's own scope rather than
	// leaving it out. It is what evidence collection wants: a vendored license
	// belongs to the component that vendored it, and is needed to say so.
	ScopeTree
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL