Changelog#
All notable changes to this project will be documented in this file. Each version listed corresponds to a release published on PyPI.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
1.0.1 - 2026-10-15#
Changed#
tethered1.0.0 or newer is required (was 0.5.1).
Fixed#
- A scan runs behind a proxy on another host. With
HTTPS_PROXYorALL_PROXYset, or on Windows and macOS a proxy in the system's settings, every scan stopped withBlocked by tethered: <proxy> is not in the allow list: licenseal's egress allowlist held the registry hosts but not the proxy that carried the requests to them, and no setting could add it. licenseal now chooses the proxy itself, sends the registry requests through it, and allows its host and port; see Proxies. A proxy on127.0.0.1or::1was not affected.
1.0.0 - 2026-10-14#
Most of the changes since 0.4.2 close cases where a scan passed although it had missed dependencies, misread a license, or skipped a file without saying so. A check that passes on 0.4.2 can therefore fail on 1.0.0 with no change to the project. A licenseal.review.toml entry can also stop matching, which is an error, when its dependency is now reported at another version or is no longer reported. Every change of that kind is listed under Breaking changes, together with changes to defaults, report values, the review file, and install requirements.
Upgrading from 0.4.x#
Follow the 1.0.0 migration guide. It says what to change in a project, in its licenseal.review.toml, in a checked-in LICENSES.md, and in a script that reads the JSON report, and how to stay on 0.4.2 until that is done.
Breaking changes#
Install requirements#
- Python 3.11 or newer is required (was 3.10). Python 3.10 reached end of life in October 2026.
httpx0.28 or newer is required (was 0.27). The guarantee that credentials in~/.netrcare never read holds from that version on.
Projects without a lockfile#
- A missing lockfile fails strict mode. When a manifest's dependencies are resolved from the registry because no lockfile covers them, that manifest is an analysis gap: it counts in
summary.gaps, andcheckexits 1 under--strict, the default.- Commit a lockfile, or pass the new
--no-require-lockfileto accept the estimate; the manifest is then listed with severityestimateand does not fail the check. - A direct dependency that an existing lockfile does not cover counts the same way; see Package names and versions for what covering means.
- Not affected:
pom.xml,packages.config, and an erlang.mkMakefile, which have no lockfile format licenseal reads; Go; and--no-transitivescans, which read no lockfile.
- Commit a lockfile, or pass the new
- Estimated versions are marked. A version licenseal chose from a registry (for a declared range, for no constraint, or for a transitive found by walking the registry) is shown as
~1.2.3in the table and Markdown reports, with a legend line, and has"version"in the JSONestimatedarray. - A declared range resolves to a version inside it for Rust, Ruby, Hex, PHP, and .NET; the registry's latest release used to be taken whatever the range said. Rust, Ruby, Hex, and PHP take the highest matching release. NuGet takes the lowest, as NuGet itself does, and the highest for a floating version (
1.*). A constraint that cannot be read, a range no published version satisfies, a Composer branch constraint (dev-main), and a Composer exact version that Packagist does not list resolve to UNKNOWN with no version; they used to take the latest release or the first entry. A bare version in aGemfile,mix.exs,rebar.config, orcomposer.jsonis an exact pin. - npm and Cargo ranges hold every bound. A range with a prerelease bound used to lose its other bounds:
>=2.0.0-rc.1 <2.0.0resolved to2.0.0, and<2.0.0could select a prerelease of2.0.0. Both now resolve as npm and Cargo read them, which also decides whether a lockfile covers such a range. A Cargo requirement=1.2or=1is the range Cargo reads it as, marked as an estimate; crates.io used to be asked for the literal version1.2. A malformed npm hyphen range is UNKNOWN; it used to end the scan with a traceback. - An exact pin resolves under the spelling the registry lists. A RubyGems or NuGet pin written with other segments than the published version (
1.2for1.2.0, or1.2.0.0) names that release. Such a row was UNKNOWN or lacked its dependencies. It is reported under the published spelling, and NuGet versions are requested and reported in NuGet's normalized form, so a review entry keyed on another spelling must be keyed again. - A dependency list the registry did not return is a gap. When a package's own dependencies cannot be read (a failed request, a malformed response, an unreadable
.nuspec), the manifest that leads to the package is an analysis gap. Such a package used to look like one with no dependencies.--no-require-lockfiledoes not waive this gap. - Gradle without a
gradle.lockfile. A coordinate declared with no version is reported at the artifact's latest release, marked as an estimate, and its dependencies are scanned. A dynamic version (1.+,latest.release) is UNKNOWN with no version; its literal text used to be sent to the registries.
Package names and versions#
- Names are compared the way each registry compares them. In Go, Java, npm, and Ruby, two names that differ only in case are two packages: each is reported with its own license; one used to stand for both. A
licenseal.review.tomlentry for those ecosystems must spellpackageexactly as the report does; an entry in another case stops matching, which is an error. .NET, PHP, Rust, Hex, and R names are compared without regard to case and Python names by PyPI's normalisation rule, as before. - Names and versions are percent-encoded in every registry URL. A dependency name or version containing
/../,?,#, or%xxcould make a lookup return another package's metadata, and the row passed with that package's license. One that would still address another resource is not sent: the dependency is UNKNOWN under its declared name. The URL of a well-formed name is unchanged. - Every declared version is scanned. When manifests in one tree declare a package with different version constraints, each one is resolved, walked, and reported under its own manifest. Only the first used to be scanned, so the other version and everything below it were missing.
- A lockfile covers a dependency only at a version its manifest admits. A lockfile used to cover a declared dependency whenever it held a package of that name. A manifest that is ahead of its lockfile, or that pins another version than a lockfile elsewhere in the tree, is now resolved from the registry for that dependency and counts as a manifest without a lockfile, an analysis gap under the default
--require-lockfile. A locked prerelease is accepted for any range, since whether a range admits one depends on settings licenseal does not read. - A locked row is a direct dependency only at a declared version. Another locked version of a directly declared package, such as a nested copy in an npm lockfile or a second major in
Cargo.lock, is a transitive: in JSON itsdepthis 1 and itssourceis empty. A requirement that did not resolve is reported even when a row of the same name resolved at a version that does not satisfy it. - .NET reports every distinct version. A package pinned at different versions in two
paket.lockgroups is one row per version; the first entry used to win. The.nuspecwalk reports another version of a directly referenced package as its own row. The production rows of a--devscan now equal the rows of a--no-devscan. - Hex. A review entry for a dependency that
mix.exsrenames is keyed on its hex.pm package name, not on the local name; an entry written under the local name must be scaffolded again.
The project's own license#
- Only manifests in the scanned directory decide it. The project license, which every dependency is judged against, is read from the manifests in the scanned directory itself and never from a nested package. With none declaring one the project is checked as
Proprietary, as before. A monorepo whose license came from a sub-package gets different verdicts; scan the sub-package with--pathto use its license. - An unrecognised declared value is judged as
Proprietary, with a warning that names the file and the value. It used to make every dependency UNKNOWN. - An unrecognised arm of a dual license is a
Proprietaryarm. ProjectAGPL-3.0-only OR Commercialused to drop the unrecognised arm, so copyleft dependencies passed. They are violations now, as underAGPL-3.0-only OR Proprietary. - A
Directory.Build.propsin the scanned directory can declare it. ItsPackageLicenseExpression, orPackageLicenseUrl, is read after the project files in that directory. A .NET solution that declares its license only there was checked asProprietary. check, andinit-review-filewhen it scans, print the file the project license came from, and warn when manifests in the scanned directory declare different licenses. JSON has a new top-levelproject_license_source, and the table and Markdown reports show the file beside the project license, or say thatProprietaryis assumed and why, so the header of a checked-inLICENSES.mdchanges once.
Dependencies that do not come from a registry#
- One rule for every ecosystem. An off-registry dependency (one installed from a git repository, a direct URL, or a local path) is reported as one UNKNOWN row that carries its
origin, and no registry is asked about it. Before, such a dependency was dropped, reported UNKNOWN with no way to review it, or looked up on the public registry under the same name, depending on the ecosystem and the file; the lookup could report a private fork with the public package's license. The forms recognised in each ecosystem are listed on each ecosystem page. A name that one manifest takes from such a source and another from the registry, or that two manifests take from two different repositories, is one row per source. - A local path is the project's own code only when the scan reads it. A path dependency on a directory the scan walks gets no row; that directory's own manifest is scanned. Any other path is a row: one outside the scanned directory, an archive (a wheel, an npm tarball, a JAR), a directory under a skipped name such as
vendor/, a nested repository, an--exclude-dirsdirectory, and a path that does not exist. - Alternate registries are unchanged. A package from a private index or mirror (a Poetry
source, a uv index, aGemfilesourceblock, a Cargo alternate registry) is still resolved from the public registry by name. Crates thatCargo.lockrecords from an alternate registry are now reported; they were dropped. - An off-registry dependency that no lockfile records is a gap. Nothing is fetched for a git, URL, or path dependency, so only a lockfile lists what it requires. A manifest that declares one with no lockfile entry for it is an analysis gap, which
--no-require-lockfileaccepts like any other. A Mixpath:dependency and a .NET project reference are exempt when the project'smix.lockor NuGet lockfile is present. GISTsections ofpaket.lockare no longer read as NuGet packages.
The review file#
- Off-registry dependencies are reviewed by source. A
licenseal.review.tomlentry for an off-registry dependency carries asourcekey holding the row'sorigin, andversionis optional beside it. Only an entry withsourcematches such a row, and an entry withsourcenever matches a registry package.init-review-filewrites thesourceline. A review keyed on a branch or a tag keeps applying when the branch moves; a commit pin or a lockfile gives a key that does not. init-review-filescans ascheckdoes. Without--from-reportit used to resolve direct dependencies only and to ignore lockfiles, so it missed flagged transitives and could write a version thatcheckdoes not report; the nextcheckthen failed with "did not match any resolved package versions". It now reads lockfiles and the transitive graph withcheck's defaults, and takes correspondingly longer.- A review entry whose license has unpaired parentheses is rejected. It used to be accepted as an override that could not be classified.
- A review entry's license must name a license. An entry whose
licenseholds something that is no license, such as the typoMTI, is rejected with an error that names the entry and the operand. Accepted: an SPDX identifier, current or deprecated; a source-available license licenseal recognises (BUSL-,FSL-,PolyForm-, …);Proprietary; aLicenseRef-reference. An exception afterWITHis not checked against a list. Such an entry used to be accepted, and the dependency passed strict mode as reviewed. - A review entry whose
licenseis a comma- or slash-separated list is rejected; write the expression withORorAND. It used to be read as a choice.
Dependencies that were missed are now reported#
New rows can carry new findings.
- Lockfiles without a matching manifest. A lockfile is read wherever it is found, including when no manifest yields a direct dependency for its ecosystem: a directory that holds only lockfiles, or a Gradle build that declares everything through a version catalog. Such scans were empty and passed. When none of a lockfile's packages is a discovered direct dependency, all of them are reported, as
produnless the format records its own dev/prod split. A sample or test-data lockfile outside the auto-skipped directories now adds its packages to the report; exclude its directory with--exclude-dirs. - Python.
setup.cfgis read at any depth, not only in the scanned directory.-r/--requirementincludes in requirements files are followed when they stay inside the scanned directory, and so is a setuptoolsfile:directive (install_requires = file: reqs.ininsetup.cfg,[tool.setuptools.dynamic]inpyproject.toml). A requirement followed by a comment that contains a URL is no longer dropped. - npm. In a Yarn Classic
yarn.lock, a package reached only throughoptionalDependenciesis kept, with everything below it. Inpnpm-lock.yaml, a package installed under an npm alias is reported under its real name; it used to be dropped. - Java. Dependencies declared inside Maven
<profiles>are included from every profile; activation conditions are not evaluated. A Gradle coordinate declared without a version ('group:artifact') is reported; such a build file used to produce no dependencies at all. Gradle'scompileOnlyApi,shadow,releaseApi,releaseImplementation, andreleaseRuntimeOnlyconfigurations are read as production, anddebugApi,debugImplementation,debugRuntimeOnly,developmentOnly,testFixturesApi, andtestFixturesImplementationas dev. - Maven and Gradle versions. A version that names one release is resolved whatever it looks like: a release-train name, a version that starts with a letter, the hard requirement
[1.0], Gradle's strict1.0!!. Such rows were UNKNOWN, and their dependencies were not scanned.RELEASE,LATEST, and other text that is not a version are no longer sent to deps.dev. A Gradle coordinate written with an artifact type (group:artifact:1.0@aar) is reported at its version (previously the type stayed inside the version and the row was UNKNOWN); one with a type and no version was missing from the report. - .NET.
<PackageReference>items inDirectory.Build.props/.targets, in the files those import, and inside<Choose>branches are read. Apaket.lockpackage line that ends in options (- restriction: …) is read at its pinned version. A reference whoseConditionnames Debug among other configurations (Debug Or Release) is production; only a condition that is, as a whole, a Debug / Test comparison is dev. A dependency that a.nuspecdeclares with no usable version is reported as UNKNOWN; it used to be dropped. - Go.
replace ( … )blocks are applied, and areplacewith a version on its left side applies to that version only. Ago.modrequirement that a non-emptygo.sumhas no row for is reported. - Rust. The crates.io crates that
Cargo.lockreaches only through agitorpathdependency are reported, under that dependency's own row. - PHP. A package whose vendor name starts with
php-,ext-, orlib-is no longer filtered out as a platform requirement. Without acomposer.lock, a version other than the newest now resolves its license and its dependencies: Packagist's minified metadata was read without being expanded, so those versions came back with no license and no children. - Ruby. A gem is dev only when every group it belongs to is
:developmentor:test. Gems in any other group (:production,:staging, a custom group such as:assets) or in a mixed list were dropped as dev-only. - Hex.
rebar.configdependencies under a profile other thantestanddevare production dependencies. Only top-leveldepsandprofilesterms are read, not adepslist inside anoverridesentry.mix.exsoptions are read in every spelling of their key (only:,"only":,{:only, …}), so a dev-only or renamed dependency written that way is no longer read as a plain production one.
Dependencies that are no longer reported, or are reported differently#
- Rust. Without a
Cargo.lock, the registry walk no longer follows optional (feature-gated) dependencies, which it used to expand for every crate it visited. A crate enabled only through a feature is not reported in that mode. - Ruby. A development or test group written as a string (
group "test") or as a word list (groups: %i[development test]) is recognised, so its gems are dev and leave the default scan. - .NET. With
packages.lock.jsonorproject.assets.json, group anddirect_ancestorsfollow the lockfile's dependency edges: a package reached only through aPrivateAssets="all"reference is dev, and aPrivateAssets="all"reference that a production package depends on is production. Inpaket.lock, the dependency-constraint lines under a package are no longer reported as packages. A<PackageReference>inside an<ItemGroup>whoseConditionis, as a whole, a Debug / Test comparison is dev, like one that carries the condition itself. - Python. A local wheel, archive, or directory path in a requirements file is no longer read as a package named after its first path segment and looked up on PyPI.
- Go. With a
go.sum, a row at a version other than the onego.modrequires is a transitive, not a direct dependency.replacedirectives in a dependency's owngo.modare no longer applied; Go honours them only in the main module.replacedirectives in ago.workin the scanned directory are applied to the modules it lists withuse, and override those modules' own; they used to be ignored, so a module thatgo.workredirects to a local path was looked up on deps.dev and the Go module proxy. Whengo.sumlists several versions of a required module,direct_ancestorsand theprod/devgroup follow the required version'sgo.mod; they could follow another version's. - R. A
DESCRIPTIONrequirement written(== x)is reported atx, with its license marked as an estimate when CRAN's index lists another version. The default scan used to report the indexed version. - The project's own packages. A project of the scanned tree that another manifest names in a different case has no row and is no longer looked up on the public registry: a .NET package reference to an in-tree project, and a Cargo dependency on a workspace crate. It used to be a row with the license of whatever registry package has that name (for .NET, in a
--no-transitivescan). - Production wins. A dependency that is declared as dev and is also pulled in by a production dependency is
prodeverywhere: in the registry walk, when several lockfiles are merged, for Gotoolmodules, in the Maven and NuGet walks, and inpaket.lock.--devadds rows and never turns aprodrow intodev.
Stricter license reading#
- PyPI wheel metadata. A folded
License-Expressionheader is read whole;MITcontinued byAND GPL-3.0-onlyon the next line used to resolve to MIT. A license text pasted into the legacyLicensefield is UNKNOWN; its first line used to be taken as the license. For a pinned release whose JSON carries no license, that release's own wheel metadata is read before the license of the project's latest release is borrowed. A wheel-metadata lookup that fails borrows nothing: the release is UNKNOWN unless deps.dev has that version. - License URLs. A license URL is matched on its parsed host and path. A lookalike domain, a canonical path on another site, and prose that mentions a license host no longer resolve to that license.
- Use-restricted licenses.
BSD-3-Clause-No-Military-License,BSD-3-Clause-No-Nuclear-License,BSD-3-Clause-No-Nuclear-License-2014,BSD-Protection, andUnicode-TOUare UNKNOWN for a dependency; they were classified as permissive. As the project's own license they are not recognised, and the project is checked asProprietary. - Compound expressions.
- License entries joined with
ANDkeep their grouping:Apache-2.0 OR GPL-2.0-onlyjoined withAGPL-3.0-onlywas read asApache-2.0 OR (GPL-2.0-only AND AGPL-3.0-only)and came back compatible. - Lowercase
and/orare read with SPDX precedence. - A custom
LicenseRef-*is mapped per operand, soLicenseRef-custom AND GPL-3.0-onlyis a violation under a permissive project; the reference used to swallow the rest of the expression. - Each
ORchoice is judged as a whole: underGPL-2.0-only,Apache-2.0 OR AGPL-1.0-onlyis a warning; one choice used to supply the risk class and the other the clean conflict check. - Redundant parentheses around the project's license no longer change the result.
- An
ORgets exactly the verdict of the choice the project can take:Apache-2.0 OR EPL-2.0in aGPL-2.0-onlyproject is the EPL-2.0 warning; it used to be a violation.
- License entries joined with
- Deprecated GNU license IDs.
GPL-2.0,GPL-3.0+,LGPL-3.0, and the other deprecated GNU spellings are read as their current IDs wherever they appear: inside an expression, before aWITHexception, from a license URL, from a NuGet<licenseUrl>, and in the project's license. The version-conflict rules for GPL-family projects now apply to them. Reported license strings change accordingly (GPL-2.0→GPL-2.0-only,GPL-3.0+→GPL-3.0-or-later) inlicense,detected_license,effective_license, andproject_license. A deprecated compound ID (GPL-3.0-with-GCC-exceptionand the six like it) is judged as itsWITHspelling, so the version-conflict rules apply to it too; it is shown as declared. - Malformed license strings. A license string with a dangling operator or unpaired parentheses (
MIT OR GPL-3.0-only AND,(MIT OR Apache-2.0) is never compatible: it is a violation when a license it names is one, and UNKNOWN otherwise. Its well-formed part used to be accepted. A license string longer than 1,000 characters is UNKNOWN. A review entry with any of these is rejected. A violating license joined byANDto text that is no license (AGPL-3.0-only AND Some Custom Terms) is a violation, and so is one in a string of IDs with no operator between them (MIT GPL-3.0-only). WITHexceptions. Whatever followedWITHused to be ignored unless it was a Classpath exception, and anyWITHsuffix switched the pair-level conflict rules off. Now only exceptions licenseal recognises change how a license is judged: the two Classpath exceptions relax a GPL license to weak copyleft, fourteen permission-only SPDX exceptions are judged as the bare license, andApache-2.0 WITH LLVM-exceptionis compatible in aGPL-2.0-onlyproject. A license with any other exception is a violation when the bare license is one and UNKNOWN otherwise:Apache-2.0 WITH Commons-Clauseno longer passes.- License lists with no stated meaning. A RubyGems or Hex
licensesarray with several entries, and a comma- or slash-separated list inside one license string, were read as alternatives, so the most permissive entry decided. Such a list is now judged both as alternatives and with every entry applying: when the verdicts differ, the dependency is UNKNOWN until the reading is recorded inlicenseal.review.toml. A list whose source defines it keeps that meaning (a Composer array and a Cargo/are alternatives; a deps.dev array is a conjunction), and the pairRubywithBSD-2-Clauseis read as alternatives.license_rawshows such an array comma-separated; it used to be joined withOR. - PyPI classifiers. When PyPI metadata has no usable
licensefield, every license classifier is read (previously only the first); several distinct ones follow the list rule above. - A declared proprietary license is kept. A license field or name that says
Proprietary,Commercial License,Closed source, orInternal use onlyis reported asProprietary. A permissive PyPI classifier, a Maven<url>, or a NuGet<licenseUrl>no longer replaces it. - deps.dev answers.
GPL-3.0-only AND LGPL-3.0-onlyused to be collapsed toLGPL-3.0-only, and the CDDL / GPL-with-Classpath conjunction rewritten toOR, in every license string. Both rewrites now apply only to a multi-entry license array from deps.dev; a declared or reviewed expression keeps its operators. A deps.dev array that mixes a license withnon-standardis no longer reported as that license: the dependency is resolved from its own registry, and a Go module, which has no other source, is UNKNOWN. - CRAN. A license written
<license> + file LICENSE, on a license other than MIT or BSD, is reported as<license> AND UNKNOWN: the file adds terms licenseal does not read. - A license taken from another release is marked. Three registries can only answer with metadata for a release other than the one reported: CRAN's index describes the current version of a package, hex.pm serves the license of the release published last, and a PyPI release that declares no license takes the latest release's. Such a license has
~before it in the table and Markdown reports and"license"in the JSONestimatedarray. The mark does not fail a scan. - A pinned PyPI release is never swapped for the latest one. A pinned release that PyPI does not have (a build from another index, a
+localversion) is reported at its pinned version with its license marked as above; it used to be reported as the project's latest release, so a review entry keyed on that version must be keyed again. When the lookup of a pinned release fails for another reason (a server error, a timeout), the dependency is UNKNOWN at its pinned version unless deps.dev has that version.
Files that cannot be analyzed fail strict mode#
Each of these is now an analysis gap on the file, reported on stderr and in the report. A manifest without a lockfile, and a dependency list the registry did not return, are analysis gaps too; see Projects without a lockfile.
- A TOML manifest or lockfile that is not valid UTF-8. It used to be read through the latin-1 fallback, which TOML does not allow. A file with a byte-order mark is read as before.
- A
go.modwhosegodirective is below 1.17, or missing, with a requirement that has nogo.sumrow: the modules that requirement depends on are not in the report. From Go 1.17 on, ago.modlists every module the build needs and is not affected. - A
composer.jsonthat declares a version-control,artifact, or unreadpathrepository and has nocomposer.lock: the packages such a repository provides cannot be told from registry packages and may have been resolved from Packagist. - A
setup.pythat is not valid Python 3, which was treated as declaring nothing. A malformedsetup.cfgin a subdirectory is a gap as well, now that those files are read. - A manifest or lockfile value of a type its format does not allow (
"dependencies": []inpackage.json). It used to be skipped silently or to end the scan with a traceback. - A lockfile the parser cannot use: a
pnpm-lock.yamlwithlockfileVersionbelow 6, a lockfile none of whose entries can be read, and a line-oriented lockfile with merge-conflict markers. The direct dependencies are then resolved through the registry, as before, but no longer silently. - A requirements
-rinclude, or a setuptoolsfile:directive, that is a URL, is not a relative path, is not a file, or leaves the scanned directory. - A manifest or lockfile read as text that holds NUL characters: a binary file, or UTF-16 without a byte-order mark. It used to be read as an empty file.
- An MSBuild
<Import>in aDirectory.Buildfile, or ago.workusedirectory, that is not a relative path. Such a path is no longer opened at all; on Windows a UNC value could reach a network share.
Report output#
- Markdown and table. Every dependency is one row, so the table has as many rows as JSON's
summary.total; transitives used to repeat under each direct row that shared a name. When a scan has analysis gaps, the summary line carries the gap count and an Analysis gaps section lists each file with its reason. Strings from registries and scanned files are escaped: control characters appear as\xNN(line breaks and tabs as a space), and Markdown syntax in a package name, license, or note is backslash-escaped. A checked-inLICENSES.mdchanges where any of this applies. - Table. Off a terminal and in an
-ofile, the table is written at its full width, one line per dependency, with no column dropped and no cell cut short; lines can be longer than 80 columns. In a CI log the Status column used to be cropped. - JSON. New fields:
estimated,origin, andregistry_nameon every dependency, and a top-levelproject_license_source. Thedependenciesarray has one row per ecosystem, name, resolved version, and origin, in every scan mode.diagnostics[].severitycan be"estimate".package_urlpercent-encodes the name and version (the URL of a well-formed name is unchanged) and is empty for an off-registry dependency.sourcenames the declaring manifest for a direct dependency read from a Ruby, Hex, or R lockfile; it used to be empty.
Added#
- A documentation site, at https://licenseal.shcherbak.ai/, built from the Markdown pages under
docs/. It replacesUSAGE.mdandJSON_OUTPUT.md, which now only say where each of their sections went, and holds the detail of the security model, whichSECURITY.mdsummarises. Its pages are listed for language models and coding agents at https://licenseal.shcherbak.ai/llms.txt, each with a Markdown copy. - hex.pm's limit of 100 unauthenticated requests per minute is waited out: a project with more than 100 Hex packages pauses until the registry's reported reset time and then continues; every package past the limit used to resolve to UNKNOWN. Each pause is announced on stderr with its length. The waiting is bounded, and lookups abandoned because the limit never lifted are counted in a closing warning.
check --no-require-lockfileaccepts dependencies resolved without a lockfile as estimates; see Projects without a lockfile.checkreports dependencies from an off-registry source on their own stderr line. Thecould not be resolved from registrieswarning counts failed lookups only.- TOML manifests and lockfiles written as UTF-16, UTF-32, or UTF-8 with a byte-order mark are read.
- A lockfile in a format version newer than licenseal was written for is read on a best-effort basis and reported with a warning that does not fail strict mode. Yarn Berry lockfiles up to metadata version 10 are read without one.
- Python 3.15 is supported, and the test matrix runs on it. PyYAML publishes no wheel for 3.15 yet, so installing licenseal there builds PyYAML from source. That needs no compiler, but fails where source builds are switched off (
pip --only-binary :all:,uv --no-build).
Fixed#
- Inputs that ended the scan with a traceback and no report (a manifest in a broken encoding, a registry response of an unexpected shape, a name that cannot be put in a URL, and similar) now produce an analysis gap or an UNKNOWN row, and the scan continues. A
setup.cfglicense that holds a stray%(license = 100% free) is read as declaring no license. - A
licenseal.review.toml, a--from-reportfile, or an installed skill file that is not UTF-8 or cannot be parsed ends the command with an error that names the file. init-review-filewrites every scaffold field so that it parses back unchanged. A multi-line license string from a registry used to be written partly as live TOML, which could leave a scaffold that did not parse, or plant a pre-filled review entry for another dependency. A dependency whose name, version, or source a TOML file cannot hold (a lone surrogate) gets no entry and is named in a note; it used to make the whole file unloadable.- The table report no longer fails when a dependency name contains
[, and terminal escape sequences in registry strings no longer reach the terminal, in the reports or in the messages on stderr. Bidirectional control characters and line separators are shown escaped as well, and Markdown escapes*. - The table report prints a reason as written. On a terminal, the number inside a license ID (
GPL-3.0-only), a quoted value, or a path in a details, analysis-gap, or estimate line used to be highlighted, in colours that mean a verdict elsewhere in the report. - A range resolves to the same row with and without
--no-transitivefor PyPI, npm, and RubyGems. The two modes could report different versions or licenses when a version's own registry endpoint answered 404. - With stdout redirected under a legacy code page such as cp1252, the table and Markdown formats print ASCII replacements for the glyphs the encoding lacks instead of failing with no output.
checkprinted "No dependencies to resolve." before the lockfiles were read. It now says so only when the scan found nothing.--no-transitivereports apom.xmldependency at the version its parent chain supplies (<dependencyManagement>, an imported BOM, a${property}), as the default scan does. Those rows were UNKNOWN with no version.- A Go module version that contains an uppercase letter is requested from the module proxy at the correct URL.
- The hint printed when a request to a host is blocked links to the section of
SECURITY.mdthat lists the hosts. Its link pointed at a heading that did not exist. - The
--helpscreens show each command's summary whole, show the default of--pathand--format, and say which options a command rejects together (--max-depthwith--no-transitive;--dev/--no-dev,--max-workers, and--exclude-dirswith--from-report). Help text only: no option, default, or exit code changed.
0.4.2 - 2026-09-26#
Documentation and package metadata only; CLI behavior is unchanged.
Changed#
- Refreshed the README with a workflow header, an illustrative CLI report, and a transitive-dependency diagram. Repository documentation and image links now use relative paths for local and branch previews.
- The PyPI description now comes from a concise, standalone
PYPI.mdwith absolute documentation links. Builds use it directly without rewriting README URLs.
0.4.1 - 2026-09-25#
Fixed#
check --outputno longer fails without saying why. The report goes to the file, so CI logs and pre-commit hooks used to show only the exit code; a failing check now also prints to stderr the summary line, each unreviewed finding that failed the check (violations, plus warnings and unknowns under--strict), the number of manifests that couldn't be analyzed, and the report's path. Exit codes, the written report, and checks that pass or run without--output(which print the full report) are unchanged.
0.4.0 - 2026-09-25#
Changed#
- The Markdown report no longer includes the scan's run time (
**Completed in:**), so a checked-inLICENSES.mdonly changes when the scan results do. The table output still shows the run time, and the JSON report keepselapsed_seconds.
Fixed#
uv.lockscans now include packages installed through extras (django[argon2]→argon2-cffiand everything below it). uv records those edges in[package.optional-dependencies], which the parser didn't read, so every package reachable only through a requested extra was dropped as an orphan — silently, with no analysis gap. Extras requested by the project (in its dependencies, its own extras, or its dependency groups) or by other packages, including extras nested inside extras, are followed; extras nobody requests stay out. This also works for non-package projects (e.g.tool.uv.package = false, or a root without a build system), whose lockfile entry issource = { virtual = "<path>" }: the parser only recognizedvirtual = trueas the project, so the extras requested in such a project's own extras and dependency groups would have been missed.- Files written by
check --output,init-review-file, andinstall-skillnow use LF line endings on every platform. On Windows they were written with CRLF, so a checked-in report, review file, or installed skill changed line endings depending on the platform that generated it.
0.3.0 - 2026-06-10#
Added#
- End-of-scan phase timing summary on stderr (
Phase timings: discovery …s, transitive walk …s, batch pre-pass …s, license resolution …s) — the companion to the per-host traffic summary: the host counts say where the requests went, the phase line says where the wall-clock went. Together they make a slow scan diagnosable at a glance (a longtransitive walkmeans a no-lockfile registry walk; a longlicense resolutionalongside a largecrates.iorequest share means the policy-mandated 1 req/s fallback tail).
Changed#
- The deps.dev batch pre-pass now fans every ecosystem's
versionbatchchunks through one shared threadpool instead of running up to seven sequential per-ecosystem rounds. Polyglot scans overlap their batch POSTs while the combined in-flight POST count keeps the same ceiling a single-ecosystem scan has, so the endpoint sees no more concurrent load than before. On polyglot stress repos the batch phase median dropped ~40% (npm + Python: 2.2s → 1.3s; Rust + npm + Python: 1.5s → 0.9s). Scan output is unchanged — verified per-dependency on one repo per supported ecosystem (all ten), each in both--no-devand--devmodes.
0.2.1 - 2026-06-10#
Added#
- End-of-scan registry traffic summary on stderr (
Per-package registry requests: N (host: n, …)), so a slow scan explains itself — a largecrates.ioshare means the policy-mandated 1 req/s fallback tail dominated; a large PyPI/npm share means a no-lockfile transitive walk.
Fixed#
- Cargo
[workspace.dependencies]is now treated as the version catalog it is: an entry becomes a dependency only when a workspace member references it withdep = { workspace = true }(group attribution from the referencing table, version stitched from the catalog — the same model as .NET Central Package Management). Previously every catalog entry was emitted as a direct prod dependency, so entries no member referenced — absent fromCargo.lockby definition — sent the transitive resolver on a crates.io registry walk of dependency trees the project never builds. On a large real-world Rust workspace this fabricated 789 phantom packages (36% of the Rust rows), three spurious GPL violations, and a 47-minute scan that now completes in ~20 seconds. Member-sideworkspace = truereferences also now carry their declaring table's dev/prod group instead of being dropped.
0.2.0 - 2026-06-10#
Added#
- Pair-level compatibility overrides for license pairs the coarse risk matrix cannot express. GPL-family version conflicts (a GPLv3-family dependency —
GPL-3.0-*,LGPL-3.0-*,AGPL-3.0-*— in aGPL-2.0-onlyproject, or aGPL-2.0-onlydependency in aGPL-3.0-*/AGPL-3.0-*project),Apache-2.0in aGPL-2.0-onlyproject, and FSF-documented GPL-incompatible weak copyleft (EPL-1.0,CDDL-*,MPL-1.1) in any GPL-family project now report violations instead of false-cleancompatibleverdicts.EPL-2.0in a GPL-family project reports a warning, because its GPL compatibility depends on a secondary-license designation that package metadata cannot reveal.-or-laterforms are exempt where the upgrade clause resolves the conflict, and anORexpression escapes when a conflict-free arm is electable. The overrides only ever strengthen the matrix verdict, and they apply to every arm of a multi-licensed project.
Changed#
GPL-2.0-with-classpath-exceptionandGPL-* WITH Classpath-exception-*now classify as weak copyleft instead of strong copyleft — the Classpath exception permits linking independent modules regardless of their license. OpenJDK-derived andjavax.*/jakarta.*artifacts in permissive projects now report warnings instead of false violations (which failed CI even under--no-strict).- A dual-licensed project (top-level
OR, e.g.AGPL-3.0-only OR Proprietary) is now checked against the strictest of its arms: dependencies must be compatible with every license the project distributes under, so the commercial arm of an open-core project flags copyleft dependencies instead of silently passing them.
Fixed#
- The aopalliance license URL now resolves to the permissive
LicenseRef-Public-Domainsentinel instead of a barePublic-Domainstring that no risk rule recognized, which routed a known public-domain artifact to manual review. The hyphenatedpublic-domainpublisher spelling normalizes the same way.