Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
db99933
Add PEP 751 pylock.toml resolved-dependency support
bact Sep 2, 2026
60e8138
Fix provenence
bact Sep 4, 2026
35cafb5
Add uv.lock and pdm.lock
bact Sep 4, 2026
1df753a
Fix bugs in uv.lock and pdm.lock support
bact Sep 4, 2026
dadd0fa
Prevent clash from malform [project]
bact Sep 4, 2026
13ce345
Add Piplock.file support
bact Sep 4, 2026
54e92ec
Add pinned requiremenst.txt support
bact Sep 4, 2026
9d346a2
Fix type annotations
bact Sep 4, 2026
dc256c0
Fix lock file bugs
bact Sep 5, 2026
6b6ce1b
Fix lock file info leaks and malform handling
bact Sep 5, 2026
4d07e44
Fix lock files bugs
bact Sep 5, 2026
52850b3
Merge branch 'main' into lock-formats-pep751
bact Sep 5, 2026
eea1608
Fix lock file bug per reviews
bact Sep 5, 2026
c71ad01
Fix Poetry parity
bact Sep 5, 2026
2ec6132
Split tests
bact Sep 5, 2026
086ac8e
Fix grammar
bact Sep 5, 2026
6e4d3ee
Dedup
bact Sep 5, 2026
5c787e3
Fix bugs
bact Sep 5, 2026
fd0e427
Fix canonicalize names
bact Sep 6, 2026
e1b8017
Fix test fixture timestamp bug
bact Sep 6, 2026
250dee0
Lock file bug fixes
bact Sep 6, 2026
6dea355
Update lock-files design doc
bact Sep 6, 2026
0ce4286
Fix bugs
bact Sep 6, 2026
9388008
Update stale docs and checks
bact Sep 6, 2026
974e6d7
Normalize PURL
bact Sep 7, 2026
95e7872
Update docs
bact Sep 7, 2026
8611471
Fix comments and minor bugs
bact Sep 8, 2026
c974b81
Add diagram and fix minor bugs
bact Sep 8, 2026
8052f7d
Refactor for reuse / file size
bact Sep 8, 2026
b0b9bba
Fix consistency / function boundary
bact Sep 8, 2026
e22566f
Fix requires-python bug
bact Sep 8, 2026
e5014f3
Update docs
bact Sep 8, 2026
a8d21dc
Fix license noassertion bug
bact Sep 8, 2026
43416bb
Fix build_license_elements bug
bact Sep 8, 2026
8486b6d
Fix uv.lock bug
bact Sep 8, 2026
b2cf21f
Update AGENTS.md
bact Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,219 @@ Pitloom is invoked from several usage surfaces (CLI, the Hatchling build hook, t
- **Consolidate Patterns**: Extract duplicated logic into shared utilities, constants files, or decorators immediately. Don't copy-paste code.
- **Enforce File Size Limits**: Strictly obey the ~400-500 lines soft limit. Split files *before* they become a problem.

## Recurring bug patterns

General-purpose failure modes that have recurred across more than one
subsystem -- worth checking for by name in any code that resembles the
shape described, not just the module where each was first found.

- **`None` vs `[]`/`{}` (empty-but-present) is a distinct signal, not two
spellings of the same thing.** In a cascade/fallback/gap-fill chain,
`None` (or "absent key") means "this source doesn't apply here, try
the next one"; an empty-but-real container means "this source is
valid and authoritative, with zero results -- stop looking." Confusing
them has recurred in unrelated places: a truthiness check
(`if some_list:`) used where "was a result produced at all" was
needed, silently colliding two cases that should stay distinct; a
`dict.get(key, [])`-style default that treated "key legitimately
absent" the same as "key present with an empty value"; a source-
priority cascade that couldn't tell "this source is real but empty"
from "this source doesn't apply." Before writing `x or default`,
`dict.get(key, [])`, or `if some_container:`, ask whether the empty
case and the absent case are supposed to behave the same -- they
usually aren't.
- **Same bug, provenance-flavoured: gate a "was this field explicitly
declared" check on presence of the raw source key, never on the
resolved value's truthiness.** A metadata producer building a
`provenance` dict for a container field (`keywords`, `urls`,
`dependencies`, `authors`, `license_files`, ...) with `if parsed_value:
provenance["field"] = ...` silently fails to record provenance for an
explicitly-declared-but-empty value (`dependencies = []`,
`install_requires =`) -- indistinguishable downstream from the field
never having been mentioned at all. This recurred independently across
five separate `ProjectMetadata` producers in one PR
(`_pyproject.py`, `_setuptools_py.py`, `_setuptools_cfg.py`,
`_poetry.py`, `hatchling.py`) before all five were fixed to check
presence in the raw source (`"key" in raw_dict`/`kwargs`/`core.config`)
instead. When adding a new container field or a new metadata producer,
grep every existing producer's `provenance[...]` assignments for the
same field and match whichever check style they already settled on.
- **The presence signal must survive every merge/inheritance boundary,
or the fix is cosmetic.** `merge_project_metadata()` only treats a
falsy value (empty container **or** `None`/other falsy scalar) as
authoritative when *its own* provenance key says so -- so a producer
that resolves the presence check correctly but is never checked
against real merge call sites can still lose the signal in practice.
The same masking happens one layer down: `configparser`'s `[DEFAULT]`-
section value inheritance makes `"key" in cfg.items(section)` true even
when *that section* never declared `key` -- a presence check must
read the section's own keys only (not the merged view) or a shared
default gets misread as an explicit per-section declaration. And an
upstream resolver that silently collapses "couldn't fully resolve" to
the same empty container as "genuinely empty" (e.g. an AST list
literal with one unresolvable element silently dropping just that
element instead of invalidating the whole literal) reintroduces the
exact ambiguity a presence check downstream is trying to eliminate --
propagate "unresolvable" as its own outcome, distinct from both
"absent" and "empty".
- **The provenance dict's tri-state signal isn't container-specific --
it's the same rule for a scalar that can legitimately resolve to
`None`.** `merge_project_metadata()`'s "was this explicitly declared"
check special-cased `primary_value is None` unconditionally, so no
scalar field's `None` could ever be protected as a deliberate answer,
only an absent one -- even when its own producer had confirmed
provenance for it. Three producers worked around this instead of
fixing it: `_poetry.py`'s `python = "*"` (Poetry's "explicitly no
constraint" convention), `_setuptools_cfg.py`'s `python_requires =`,
and `_setuptools_py.py`'s `python_requires=""` all truthy-gated their
own `provenance["requires_python"]` write -- correctly avoiding a
provenance/value mismatch (claiming "declared" for a field the merge
would then silently overwrite anyway), but at the cost of that field
never being protected against a lower-priority source's real,
possibly wrong, constraint. A producer that gates a scalar's
provenance on the *resolved value's truthiness* rather than the *raw
source key's presence* is treating a symptom of an asymmetric merge
condition, not a genuine ambiguity in its own data -- the fix belongs
in the shared merge function once, so every field type (present or
future, scalar or container) gets the same rule, not a per-producer
workaround that has to be independently rediscovered next time.
- **Compare domain identifiers the way the ecosystem/spec does, not as
raw strings.** A raw `==`/dict-key comparison silently fails to match
values that a spec treats as equivalent (e.g. PEP 503 package-name
canonicalization: case-fold, `-`/`_`/`.` treated as interchangeable).
Whenever two identifiers of the same kind are compared or one is used
as a dict key, canonicalize both sides first per the format/spec that
defines them, rather than assuming byte-for-byte equality is enough.
- **Version equality is PEP 440, never SemVer, and the two must not be
conflated in an explanation or a docstring.** Pitloom's own
`is_same_version()` compares two version strings via
`packaging.version.Version` equality: `"1.0"` == `"1.0.0"` ==
`"1.0.0.0"` (trailing-zero-padded normalization), a fixed, narrow
notion of "the same release" -- not "the latest release compatible
with 1.0" and not a caret/tilde-style range (`^1.0.0` accepting
`1.0.1`). The two are easy to blur in prose (a reader's SemVer
intuition reads "same version" as "compatible version"), so an
explanation of a version-equality check must say "PEP 440 equality",
not bare "same version", and must not describe it in range/
compatibility terms. See "Version comparison: PEP 440, not SemVer"
in `docs/dependency-sources.md` for the user-facing version of this
same distinction.
- **The same PEP 440-not-raw-string rule applies to detecting version
*conflicts*, not just equality checks in prose.** A duplicate-name
entry across a lock file's own `[[package]]` list (the same
canonical name appearing more than once, e.g. once per marker
branch) needs `is_same_version()` before being treated as a real
conflict -- `"1.0"` and `"1.0.0"` from two different branches are the
same release, not a conflict to warn about and drop. A sibling that
skips this check (comparing the raw version strings, or dropping
every duplicate name outright regardless of whether the versions
agree) both over-warns on non-conflicts and under-reports a real,
agreeing dependency that every other sibling format would have kept.
- **A private third-party API (`obj._attr`) does not owe you any
structural guarantee beyond what it happens to return today.** E.g.
`packaging.markers.Marker()._markers` does not pre-group same-
precedence-level boolean terms -- an unparenthesized `A or B and C`
parses to the flat list `[A, 'or', B, 'and', C]`, and the spec's real
precedence has to be reconstructed by the caller, not assumed from the
shape of the list. When consuming a private/internal structure,
verify its actual shape interactively before writing logic that folds
over it, and prefer mirroring that same library's own *public*
algorithm for the equivalent operation over inventing a new one.
- **Warning/error/log wording drifts across sibling modules that perform
the same kind of check.** When several modules of the same family
(e.g. one per supported file format) each need to warn about the same
handful of malformed-input shapes, factor the shared message into one
helper/constant and have every sibling call it, instead of hand-
rolling a similarly-worded message per module. When adding a new check
to one sibling, check whether the others need the identical check
too.
- **A decode/parse helper that only catches the format-specific
exception can still crash on a lower-level encoding failure.**
`tomllib`/`tomli`, `json`, and text-mode `open(..., encoding="utf-8")`
all raise a bare `UnicodeDecodeError` for invalid bytes -- separate
from `TOMLDecodeError`/`json.JSONDecodeError`. A "load and gracefully
degrade on bad input" helper needs to catch the encoding-level
exception alongside the format-level one, or a bad-encoding file
crashes instead of degrading like every other malformed-input case.
- **A doc/docstring claim about "how this mechanism decides" needs to be
re-verified against the actual code before being trusted or restated**
(see the `physical_path`/`distribution_path` and "how surface X does
Y" rules above -- the same failure mode recurs in any doc describing
a cascade, fallback, or precedence order: re-read the current
implementation before repeating or extending a prior description of
its behavior, rather than assuming an existing doc still matches it).
- **Picking one candidate from an unordered collection needs an explicit,
stable tie-break whenever the result must be deterministic** (see "SBOM
output" above). `{u.get("packagetype"): u for u in urls}`-style
dict-comprehension overwrite, or "first item in a list", silently makes
the choice depend on whatever order an external API/dict/set happens to
produce -- not a contract Pitloom controls. Sort candidates by a stable
key (filename, name, version) before picking one; never rely on
insertion/iteration order as the tie-break.
- **Reusing a helper outside the contract it was actually built for
silently narrows behavior.** A helper written for one caller's specific
shape (`single_exact_pin()`: a lock file's `version` field, which is
always *exactly one* PEP 440 specifier clause) can look like a
reasonable fit for a superficially similar but looser case (a general
PEP 508 dependency string, which may legally combine an exact `==`
clause with another, non-conflicting clause, e.g. `foo==1.0,!=1.0.dev0`)
-- and silently reject valid input the narrower helper was never asked
to handle. Before reusing a helper in a new call site, check its
docstring's stated preconditions against what the new call site can
actually receive, not just whether the return type matches.
- **A dedup/conflict-exclusion fix must check every path that can produce
an entry for the same identity, not just the path the original bug was
in.** A fix that partitions input into "the bucket the bug lived in"
(now correctly deduplicated) and "everything else, passed through
unfiltered" can silently reintroduce the exact double-emission bug it
was meant to fix, via the passthrough bucket, the moment the same
identity (e.g. a canonicalized package name) can appear in *both*
buckets. After adding conflict-exclusion logic for one shape of
duplicate, ask whether the same identity could also reach the output
through an entirely different, unfiltered code path.
- **A test fixture/mock that models an external system's shape must be
updated in lockstep with what the production code under test actually
inspects.** A duck-typed stand-in (e.g. a fake Hatchling `core.config`)
that only populates the one field an earlier version of the code
happened to check gives false confidence once the code is fixed to
check more fields the same way -- the fixture still returns all-green
because it was never asked to model the new field, not because the fix
is correct. When broadening a check across several fields, broaden the
fixture that backs its tests across the same fields in the same change,
or the new branches go untested despite "the tests pass."
- **A source that can legitimately resolve to zero entries needs its own
"is this genuinely a file of this format" check, or an empty result
becomes indistinguishable from a wrong file.** In a priority cascade
(e.g. `_locked_dependencies.py` picking among `poetry.lock`/`pdm.lock`/
`pylock.toml`/`uv.lock`/`Pipfile.lock`/`requirements.txt`), a resolver
that genuinely produces zero packages must still look different from an
unrelated/truncated/hand-edited file that merely happens to be found
under that format's filename -- otherwise the latter silently wins the
cascade over a real, lower-priority lock file via a spurious
authoritative-empty result. Check for the format's own identifying
top-level marker (`poetry.lock`'s string `metadata.lock-version`,
`pdm.lock`'s string `metadata.lock_version`, `Pipfile.lock`'s int
`_meta.pipfile-spec`, `uv.lock`'s flat int `version`) before trusting an
empty package list as real, not just when it's non-empty. This was
missed for `uv.lock` well after the identical check had already been
added to three sibling formats -- when a new source joins an existing
cascade/fallback family, check whether it needs the same class of guard
every existing sibling already has, not just the guards relevant to
the bug that prompted adding the new source.
- **A presence-only check (`find_first_present_key()`-style: "is any of
these keys present at all") silently misfires the moment one key in
the set is genuinely boolean-valued instead of presence-implies-true.**
`Pipfile.lock`'s non-registry-source keys are almost all presence-only
(a `"git"`/`"path"`/`"url"` string means "non-registry, full stop"),
but `"editable"` is schema-legal as an explicit `false` -- a naive
presence check would misread `"editable": false` as "editable source,
exclude" instead of "not editable, no exemption needed here." Before
reusing a presence-only helper across a whole key set, check each key's
real schema: a key that can legitimately carry a meaningful `false` (or
any other falsy-but-real value) needs its own value check, not just a
presence check, even when every other key in the same set is fine with
presence alone.

## CLI output

Unix philosophy. Consistent, predictable, parseable.
Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
Last-Modified: 2026-09-04
Last-Modified: 2026-09-08
SPDX-FileCopyrightText: 2026-present Arthit Suriyawongkul
SPDX-FileType: DOCUMENTATION
SPDX-License-Identifier: CC0-1.0
Expand Down Expand Up @@ -41,6 +41,10 @@ and this project adheres to
- Add PEP 639 `[project.license-files]` support: each declared license
file gets a `software_File` element at the real wheel's
`.dist-info/licenses/` path and a `hasDeclaredLicense` relationship ([#207])
- Add resolved-dependency parsing for `loom project`/`loom generate`
from `pylock.toml` (PEP 751), `uv.lock`, `pdm.lock`, `Pipfile.lock`,
and a fully pinned `requirements.txt` -- see [Dependency sources and
precedence](docs/dependency-sources.md) ([#208])

### Fixed

Expand Down Expand Up @@ -80,6 +84,7 @@ and this project adheres to
[#204]: https://github.com/bact/pitloom/pull/204
[#205]: https://github.com/bact/pitloom/pull/205
[#207]: https://github.com/bact/pitloom/pull/207
[#208]: https://github.com/bact/pitloom/pull/208

## [0.17.0] - 2026-08-30

Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
**Pitloom** automates the generation of [SPDX 3]-compliant SBOMs for
AI models and Python projects. It reads metadata directly from Python
packages and AI models (GGUF, ONNX, PyTorch, Safetensors), producing
standardized SPDX 3 JSON artifacts -- as a CLI, a library, or a native
standardised SPDX 3 JSON artifacts -- as a CLI, a library, or a native
Hatchling build hook.

When used with Hatchling, Pitloom automatically embeds the generated
Expand Down Expand Up @@ -91,7 +91,10 @@ Merkle root) is backend-aware for Hatchling, setuptools, Poetry,
PDM-backend, and Flit-core, and falls back to a Hatchling-based
heuristic with a warning for other backends -- see
[Command line](docs/cli.md#generate-an-sbom) for the full limitation
note.
note. If a lock file (`pylock.toml`, `uv.lock`, `poetry.lock`, `pdm.lock`,
`Pipfile.lock`, or pinned `requirements.txt`) is present, Pitloom includes
its exact resolved dependencies automatically -- see
[Dependency sources](docs/dependency-sources.md).

Generate an **Analyzed SBOM** from a pre-built wheel
(extracting bundled binaries as phantom dependencies):
Expand Down Expand Up @@ -160,8 +163,9 @@ loom enrich path/to/model.safetensors --project-dir . -o model.enrich.spdx3.json

Register the fragment under `[tool.pitloom.fragment]` and re-run
`loom project`/`loom generate` to merge it in. See
[`sbom-enrichment.md`](working-docs/design/sbom-enrichment.md) for the
full surface list (Python API, Hatchling hook, GitHub Action, Skill).
[Command line](docs/cli.md#enrich-an-sbom) and
[Agent Skills](docs/agent-skills.md) for the full surface list
(Python API, Hatchling hook, GitHub Action, Skill).

### Hatchling build hook

Expand Down Expand Up @@ -461,7 +465,7 @@ and a worked example.

- [SPDX 3.0 Specification](https://spdx.dev/wp-content/uploads/sites/31/2024/12/SPDX-3.0.1-1.pdf)
- [PEP 770 – SBOM metadata in Python packages](https://peps.python.org/pep-0770/)
- [Design document](working-docs/design/architecture-overview.md)
- [Resources and standards list](docs/resources.md)
- Bennet et al., [“Implementing AI Bill of Materials with SPDX 3.0”](https://www.linuxfoundation.org/research/ai-bom),
The Linux Foundation, 2024.

Expand Down
2 changes: 1 addition & 1 deletion docs/agent-skills.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
Created: 2026-08-11
Last-Modified: 2026-08-14
Last-Modified: 2026-09-08
SPDX-FileCopyrightText: 2026-present Arthit Suriyawongkul
SPDX-FileType: DOCUMENTATION
SPDX-License-Identifier: CC0-1.0
Expand Down
2 changes: 1 addition & 1 deletion docs/ai-model-formats.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
Created: 2026-08-14
Last-Modified: 2026-08-14
Last-Modified: 2026-09-08
SPDX-FileCopyrightText: 2026-present Arthit Suriyawongkul
SPDX-FileType: DOCUMENTATION
SPDX-License-Identifier: CC0-1.0
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
Created: 2026-08-11
Last-Modified: 2026-08-29
Last-Modified: 2026-09-08
SPDX-FileCopyrightText: 2026-present Arthit Suriyawongkul
SPDX-FileType: DOCUMENTATION
SPDX-License-Identifier: CC0-1.0
Expand Down
4 changes: 2 additions & 2 deletions docs/claude-code-plugin.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
Created: 2026-08-11
Last-Modified: 2026-08-11
Last-Modified: 2026-09-08
SPDX-FileCopyrightText: 2026-present Arthit Suriyawongkul
SPDX-FileType: DOCUMENTATION
SPDX-License-Identifier: CC0-1.0
Expand Down Expand Up @@ -76,7 +76,7 @@ Either trigger path works, same as standalone Skills:

All arguments are optional. See the [Agent Skills](agent-skills.md) page
for what each of the three Skills actually does and worked-example
recipes -- the behavior is identical to the standalone install, only the
recipes -- the behaviour is identical to the standalone install, only the
invocation prefix changes.

## Verifying it works
Expand Down
Loading
Loading