# Changelog

All notable changes to this project will be documented in this file. Each version listed corresponds to a release published on [PyPI](<https://pypi.org/project/licenseal/>).

The format is based on [Keep a Changelog](<https://keepachangelog.com/>), and this project adheres to [Semantic Versioning](<https://semver.org/>).

## [1.0.1](<https://github.com/shcherbak-ai/licenseal/compare/v1.0.0...v1.0.1>) - 2026-10-15

### Changed

- `tethered` 1.0.0 or newer is required (was 0.5.1).

### Fixed

- **A scan runs behind a proxy on another host.** With `HTTPS_PROXY` or `ALL_PROXY` set, or on Windows and macOS a proxy in the system's settings, every scan stopped with `Blocked 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](<https://licenseal.shcherbak.ai/security/network/#proxies>). A proxy on `127.0.0.1` or `::1` was not affected.

## [1.0.0](<https://github.com/shcherbak-ai/licenseal/compare/v0.4.2...v1.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](<https://licenseal.shcherbak.ai/guides/migrate-to-v1/>). 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.
- `httpx` 0.28 or newer is required (was 0.27). The guarantee that credentials in `~/.netrc` are 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](<https://licenseal.shcherbak.ai/concepts/check-failures/#analysis-gaps>): it counts in `summary.gaps`, and `check` exits 1 under `--strict`, the default.

  - Commit a lockfile, or pass the new `--no-require-lockfile` to accept the estimate; the manifest is then listed with severity `estimate` and does not fail the check.
  - A direct dependency that an existing lockfile does not cover counts the same way; see [Package names and versions](<#package-names-and-versions>) for what covering means.
  - Not affected: `pom.xml`, `packages.config`, and an erlang.mk `Makefile`, which have no lockfile format licenseal reads; Go; and `--no-transitive` scans, which read no lockfile.
- **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.3` in the table and Markdown reports, with a legend line, and has `"version"` in the JSON `estimated` array.
- **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 a `Gemfile`, `mix.exs`, `rebar.config`, or `composer.json` is 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.0` resolved to `2.0.0`, and `<2.0.0` could select a prerelease of `2.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.2` or `=1` is the range Cargo reads it as, marked as an estimate; crates.io used to be asked for the literal version `1.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.2` for `1.2.0`, or `1.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-lockfile` does 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.toml` entry for those ecosystems must spell `package` exactly 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 `%xx` could 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 its `depth` is 1 and its `source` is 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.lock` groups is one row per version; the first entry used to win. The `.nuspec` walk reports another version of a directly referenced package as its own row. The production rows of a `--dev` scan now equal the rows of a `--no-dev` scan.
- **Hex.** A review entry for a dependency that `mix.exs` renames 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 `--path` to 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 `Proprietary` arm.** Project `AGPL-3.0-only OR Commercial` used to drop the unrecognised arm, so copyleft dependencies passed. They are violations now, as under `AGPL-3.0-only OR Proprietary`.
- **A `Directory.Build.props` in the scanned directory can declare it.** Its `PackageLicenseExpression`, or `PackageLicenseUrl`, is read after the project files in that directory. A .NET solution that declares its license only there was checked as `Proprietary`.
- `check`, and `init-review-file` when 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-level `project_license_source`, and the table and Markdown reports show the file beside the project license, or say that `Proprietary` is assumed and why, so the header of a checked-in `LICENSES.md` changes 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](<https://licenseal.shcherbak.ai/ecosystems/>). 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-dirs` directory, 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, a `Gemfile` `source` block, a Cargo alternate registry) is still resolved from the public registry by name. Crates that `Cargo.lock` records 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-lockfile` accepts like any other. A Mix `path:` dependency and a .NET project reference are exempt when the project's `mix.lock` or NuGet lockfile is present.
- `GIST` sections of `paket.lock` are no longer read as NuGet packages.

#### The review file

- **Off-registry dependencies are reviewed by source.** A `licenseal.review.toml` entry for an off-registry dependency carries a `source` key holding the row's `origin`, and `version` is optional beside it. Only an entry with `source` matches such a row, and an entry with `source` never matches a registry package. `init-review-file` writes the `source` line. 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-file` scans as `check` does.** Without `--from-report` it used to resolve direct dependencies only and to ignore lockfiles, so it missed flagged transitives and could write a version that `check` does not report; the next `check` then failed with "did not match any resolved package versions". It now reads lockfiles and the transitive graph with `check`'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 `license` holds something that is no license, such as the typo `MTI`, 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`; a `LicenseRef-` reference. An exception after `WITH` is not checked against a list. Such an entry used to be accepted, and the dependency passed strict mode as reviewed.
- A review entry whose `license` is a comma- or slash-separated list is rejected; write the expression with `OR` or `AND`. 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 `prod` unless 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.cfg` is read at any depth, not only in the scanned directory. `-r` / `--requirement` includes in requirements files are followed when they stay inside the scanned directory, and so is a setuptools `file:` directive (`install_requires = file: reqs.in` in `setup.cfg`, `[tool.setuptools.dynamic]` in `pyproject.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 through `optionalDependencies` is kept, with everything below it. In `pnpm-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's `compileOnlyApi`, `shadow`, `releaseApi`, `releaseImplementation`, and `releaseRuntimeOnly` configurations are read as production, and `debugApi`, `debugImplementation`, `debugRuntimeOnly`, `developmentOnly`, `testFixturesApi`, and `testFixturesImplementation` as 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 strict `1.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 in `Directory.Build.props` / `.targets`, in the files those import, and inside `<Choose>` branches are read. A `paket.lock` package line that ends in options (`- restriction: …`) is read at its pinned version. A reference whose `Condition` names 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 `.nuspec` declares with no usable version is reported as UNKNOWN; it used to be dropped.
- **Go.** `replace ( … )` blocks are applied, and a `replace` with a version on its left side applies to that version only. A `go.mod` requirement that a non-empty `go.sum` has no row for is reported.
- **Rust.** The crates.io crates that `Cargo.lock` reaches only through a `git` or `path` dependency are reported, under that dependency's own row.
- **PHP.** A package whose vendor name starts with `php-`, `ext-`, or `lib-` is no longer filtered out as a platform requirement. Without a `composer.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 `:development` or `: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.config` dependencies under a profile other than `test` and `dev` are production dependencies. Only top-level `deps` and `profiles` terms are read, not a `deps` list inside an `overrides` entry. `mix.exs` options 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.json` or `project.assets.json`, group and `direct_ancestors` follow the lockfile's dependency edges: a package reached only through a `PrivateAssets="all"` reference is dev, and a `PrivateAssets="all"` reference that a production package depends on is production. In `paket.lock`, the dependency-constraint lines under a package are no longer reported as packages. A `<PackageReference>` inside an `<ItemGroup>` whose `Condition` is, 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 one `go.mod` requires is a transitive, not a direct dependency. `replace` directives in a dependency's own `go.mod` are no longer applied; Go honours them only in the main module. `replace` directives in a `go.work` in the scanned directory are applied to the modules it lists with `use`, and override those modules' own; they used to be ignored, so a module that `go.work` redirects to a local path was looked up on deps.dev and the Go module proxy. When `go.sum` lists several versions of a required module, `direct_ancestors` and the `prod` / `dev` group follow the required version's `go.mod`; they could follow another version's.
- **R.** A `DESCRIPTION` requirement written `(== x)` is reported at `x`, 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-transitive` scan).
- **Production wins.** A dependency that is declared as dev and is also pulled in by a production dependency is `prod` everywhere: in the registry walk, when several lockfiles are merged, for Go `tool` modules, in the Maven and NuGet walks, and in `paket.lock`. `--dev` adds rows and never turns a `prod` row into `dev`.

#### Stricter license reading

- **PyPI wheel metadata.** A folded `License-Expression` header is read whole; `MIT` continued by `AND GPL-3.0-only` on the next line used to resolve to MIT. A license text pasted into the legacy `License` field 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`, and `Unicode-TOU` are UNKNOWN for a dependency; they were classified as permissive. As the project's own license they are not recognised, and the project is checked as `Proprietary`.
- **Compound expressions.**

  - License entries joined with `AND` keep their grouping: `Apache-2.0 OR GPL-2.0-only` joined with `AGPL-3.0-only` was read as `Apache-2.0 OR (GPL-2.0-only AND AGPL-3.0-only)` and came back compatible.
  - Lowercase `and` / `or` are read with SPDX precedence.
  - A custom `LicenseRef-*` is mapped per operand, so `LicenseRef-custom AND GPL-3.0-only` is a violation under a permissive project; the reference used to swallow the rest of the expression.
  - Each `OR` choice is judged as a whole: under `GPL-2.0-only`, `Apache-2.0 OR AGPL-1.0-only` is 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 `OR` gets exactly the verdict of the choice the project can take: `Apache-2.0 OR EPL-2.0` in a `GPL-2.0-only` project is the EPL-2.0 warning; it used to be a violation.
- **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 a `WITH` exception, 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`) in `license`, `detected_license`, `effective_license`, and `project_license`. A deprecated compound ID (`GPL-3.0-with-GCC-exception` and the six like it) is judged as its `WITH` spelling, 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 by `AND` to 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`).
- **`WITH` exceptions.** Whatever followed `WITH` used to be ignored unless it was a Classpath exception, and any `WITH` suffix 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, and `Apache-2.0 WITH LLVM-exception` is compatible in a `GPL-2.0-only` project. A license with any other exception is a violation when the bare license is one and UNKNOWN otherwise: `Apache-2.0 WITH Commons-Clause` no longer passes.
- **License lists with no stated meaning.** A RubyGems or Hex `licenses` array 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 in `licenseal.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 pair `Ruby` with `BSD-2-Clause` is read as alternatives. `license_raw` shows such an array comma-separated; it used to be joined with `OR`.
- **PyPI classifiers.** When PyPI metadata has no usable `license` field, 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`, or `Internal use only` is reported as `Proprietary`. 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-only` used to be collapsed to `LGPL-3.0-only`, and the CDDL / GPL-with-Classpath conjunction rewritten to `OR`, 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 with `non-standard` is 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 JSON `estimated` array. 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 `+local` version) 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](<#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.mod` whose `go` directive is below 1.17, or missing, with a requirement that has no `go.sum` row: the modules that requirement depends on are not in the report. From Go 1.17 on, a `go.mod` lists every module the build needs and is not affected.
- A `composer.json` that declares a version-control, `artifact`, or unread `path` repository and has no `composer.lock`: the packages such a repository provides cannot be told from registry packages and may have been resolved from Packagist.
- A `setup.py` that is not valid Python 3, which was treated as declaring nothing. A malformed `setup.cfg` in 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": []` in `package.json`). It used to be skipped silently or to end the scan with a traceback.
- A lockfile the parser cannot use: a `pnpm-lock.yaml` with `lockfileVersion` below 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 `-r` include, or a setuptools `file:` 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 a `Directory.Build` file, or a `go.work` `use` directory, 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-in `LICENSES.md` changes where any of this applies.
- **Table.** Off a terminal and in an `-o` file, 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`, and `registry_name` on every dependency, and a top-level `project_license_source`. The `dependencies` array has one row per ecosystem, name, resolved version, and origin, in every scan mode. `diagnostics[].severity` can be `"estimate"`. `package_url` percent-encodes the name and version (the URL of a well-formed name is unchanged) and is empty for an off-registry dependency. `source` names 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/](<https://licenseal.shcherbak.ai/>), built from the Markdown pages under `docs/`. It replaces `USAGE.md` and `JSON_OUTPUT.md`, which now only say where each of their sections went, and holds the detail of the security model, which `SECURITY.md` summarises. Its pages are listed for language models and coding agents at [https://licenseal.shcherbak.ai/llms.txt](<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-lockfile` accepts dependencies resolved without a lockfile as estimates; see [Projects without a lockfile](<#projects-without-a-lockfile>).
- `check` reports dependencies from an off-registry source on their own stderr line. The `could not be resolved from registries` warning 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.cfg` license that holds a stray `%` (`license = 100% free`) is read as declaring no license.
- A `licenseal.review.toml`, a `--from-report` file, 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-file` writes 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-transitive` for 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.
- `check` printed "No dependencies to resolve." before the lockfiles were read. It now says so only when the scan found nothing.
- `--no-transitive` reports a `pom.xml` dependency 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.md` that lists the hosts. Its link pointed at a heading that did not exist.
- The `--help` screens show each command's summary whole, show the default of `--path` and `--format`, and say which options a command rejects together (`--max-depth` with `--no-transitive`; `--dev` / `--no-dev`, `--max-workers`, and `--exclude-dirs` with `--from-report`). Help text only: no option, default, or exit code changed.

## [0.4.2](<https://github.com/shcherbak-ai/licenseal/compare/v0.4.1...v0.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.md` with absolute documentation links. Builds use it directly without rewriting README URLs.

## [0.4.1](<https://github.com/shcherbak-ai/licenseal/compare/v0.4.0...v0.4.1>) - 2026-09-25

### Fixed

- `check --output` no 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](<https://github.com/shcherbak-ai/licenseal/compare/v0.3.0...v0.4.0>) - 2026-09-25

### Changed

- The Markdown report no longer includes the scan's run time (`**Completed in:**`), so a checked-in `LICENSES.md` only changes when the scan results do. The table output still shows the run time, and the JSON report keeps `elapsed_seconds`.

### Fixed

- `uv.lock` scans now include packages installed through extras (`django[argon2]` → `argon2-cffi` and 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 is `source = { virtual = "<path>" }`: the parser only recognized `virtual = true` as 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`, and `install-skill` now 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](<https://github.com/shcherbak-ai/licenseal/compare/v0.2.1...v0.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 long `transitive walk` means a no-lockfile registry walk; a long `license resolution` alongside a large `crates.io` request share means the policy-mandated 1 req/s fallback tail).

### Changed

- The deps.dev batch pre-pass now fans every ecosystem's `versionbatch` chunks 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-dev` and `--dev` modes.

## [0.2.1](<https://github.com/shcherbak-ai/licenseal/compare/v0.2.0...v0.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 large `crates.io` share 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 with `dep = { 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 from `Cargo.lock` by 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-side `workspace = true` references also now carry their declaring table's dev/prod group instead of being dropped.

## [0.2.0](<https://github.com/shcherbak-ai/licenseal/compare/v0.1.2...v0.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 a `GPL-2.0-only` project, or a `GPL-2.0-only` dependency in a `GPL-3.0-*` / `AGPL-3.0-*` project), `Apache-2.0` in a `GPL-2.0-only` project, 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-clean `compatible` verdicts. `EPL-2.0` in a GPL-family project reports a warning, because its GPL compatibility depends on a secondary-license designation that package metadata cannot reveal. `-or-later` forms are exempt where the upgrade clause resolves the conflict, and an `OR` expression 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-exception` and `GPL-* 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 and `javax.*` / `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-Domain` sentinel instead of a bare `Public-Domain` string that no risk rule recognized, which routed a known public-domain artifact to manual review. The hyphenated `public-domain` publisher spelling normalizes the same way.
