Long-form rationale and examples for the rules below (marked "Why:") are in agents-md-rationale.md.
- SBOM generator targeting Python/Hatchling ecosystem, outputting SPDX 3 JSON-LD.
- Private alpha, one developer. No backward compat needed yet.
- Design docs:
working-docs/design/-- future work, plans, sketches; may be discarded, not yet built. - Implementation docs and progress reports:
working-docs/implementation/-- what WAS built: decisions, why, paths rejected in service of something built, revisions. Not a user manual. - Rejected paths:
working-docs/archive/-- evaluations of something wholesale rejected. roadmap.mdstays a summary, not a duplicate: when an item's detail outgrows a bullet, move it to its own file (design/while unbuilt,implementation/once built,archive/if abandoned but worth keeping) and trim the bullet to 1-2 lines plus aSee [doc.md](doc.md)link. Small unbuilt items stay inline. Why: one copy drifts unnoticed.- Paper notes (sourced raw material for an academic write-up):
working-docs/implementation/sbom-generator-field-notes/(start at itsREADME.md) andworking-docs/implementation/ai-model-scan-security-lessons.md(an entry page; findings are in its-*parts). Add to them when a finding is worth publishing. - Test fixtures:
tests/fixtures/README.md working-docs/is internal notes only -- content can change without notice. The user-facing website (docs/) must never link into it. Reference a PR or issue number instead.- Global file size: soft limit ~400-500 lines, hard limit ~800 lines (~30KB), for ALL files (source, tests, docs). Split before crossing it.
- Naming and grouping: kebab-case, topic-first filenames. If a topic outgrows 3+ closely related files, group them in a same-named subfolder.
- Cross-linking: every split or grouped file gets a "See also" pointer near the top.
- Every commit needs a sign-off line (DCO) in the commit message.
- CHANGELOG entries: concise, no background/rationale (link the PR); ~160 chars per bullet (more only for a genuinely complex PR). Merge related PRs into one entry (
[#1], [#2]); sort entries per section by lowest PR number; skip docs/version-bump/CI-only noise. New entries go inCHANGELOG.md;CHANGELOG-archive.md(pre-0.19.0) is frozen.
- Deterministic: bit-for-bit identical across builds when input/environment unchanged.
- Idempotent: no non-deterministic data (timestamps, random UUIDs).
- Schema compliance: validate every SBOM against the primary spec (SPDX) and serialisation format before finalisation.
- The Hatchling build hook (
pitloom.plugins.hatch) reads project metadata fromself.metadataviapitloom.extract.hatchling.metadata_from_hatchling(). - The CLI (
pitloom.__main__) andgenerate_project_sbom()'s default path resolve metadata viapitloom.extract.project.read_project(). - Both paths converge on
pitloom.assemble.spdx3.document.build(). physical_pathvsdistribution_path: on-disk project-relative path vs in-package/built path; they diverge for anysrc/-layout project.software_File.namealways usesdistribution_path. Registry keys come only frompitloom.id_registry.keys.file_lookup_key. For an--allow-buildfile,physical_pathis an absolute temporary path: any consumer that joins it ontoproject_diror bakes it into a determinism-sensitive string must callpitloom.core.project.project_relative_or_fallback(physical_path, fallback), not an inlineis_absolute()check. Why: rationale.- Stage-scoped helpers take an explicit stage flag: a helper shared by a source-stage caller (
read_pyproject()) and a build-stage caller (the hook's gap-fill) needs a parameter (include_locked_dependencies=False-style), not call-site discipline; a source-only artefact (poetry.lock) must not leak into the build stage.
Pitloom runs from several surfaces: CLI (10+ subcommands), the Hatchling hook, the library API, the GitHub Action, the AI skills (skills/*/SKILL.md) and the Claude Code plugin (.claude-plugin/). A change to one surface/subcommand ships easily without the others, since each surface's own tests keep passing (--debug reached the top-level parser, but 7 of 10 subcommand generators called configure_logging() again and reverted to INFO). Skills/plugin have no test suite: when editing anything a SKILL.md documents invoking, grep skills/ for the changed command/flag/output shape and update it in the same change.
- Route a cross-cutting choice through one shared, explicit mechanism every surface already reads (
--debug-> thePITLOOM_DEBUGenv var), not per-call-site updates. - A docstring/comment saying "how surface X does Y" is a claim, not a fact: verify it against the entry-point code (
__main__.main(),plugins/hatch.py, the library functions) before trusting or writing it. - A pattern hand-copied across 3+ call sites drifts (e.g. a
WARNING:naming the skipped SBOM field): factor it into one helper/constant. - A test on one surface doesn't cover the others: for behaviour that must hold across CLI, library, hook and Action, or every subcommand, test each.
- A skill is discovered through its frontmatter
descriptiononly, and a body section does not make a command triggerable. On a CLI command/flag add or rename, enumerateadd_parser(subcommands against everyskills/*/SKILL.mddescription; keep verify (structural) vs validate (schema/SHACL) explicit; give combined asks ("generate SBOM meeting CISA") a hand-off in both skills; give every "ask a follow-up" step a non-interactive fallback. A check or recipe a skill tells the agent to run must be tried on a legitimate input where it should do nothing. Audit: skills-trigger-coverage.md.
- Honour user intent over silent fallbacks: no implicit fallback that contradicts the user's explicit instruction.
- No silent deviations: emit a clear
WARNING:log or stderr message saying what was decided and why when deviating from an instruction. - Respect the configuration cascade: CLI flags >
pyproject.toml> hardcoded defaults. - Resource efficiency: cache and route to limit network access; stream large structures; never load whole files (ML models, archives) into memory: chunked reads (
read(8192)), memory mapping or native lazy header extraction (np.lib.format, stream loading for fickling/pickle). - Explicit pin beats local environment: a version pinned by its own data source (lock file, exact
==/===) is authoritative and never overridden by introspecting Pitloom's own environment; introspection is a fallback for the unpinned case only. - Absent source data is not an error; a genuine read/access failure is. Every extractor/metadata source (model readers, project parsers, lock readers, Hub/network fetches) degrades gracefully: a field the source does not carry stays
None/empty/absent, silently, noWARNING:/ERROR:, no exception (nothing was deviated from). ReserveWARNING:/ERROR:/raising for real disruption: corruption, an unreadable/truncated archive, a network error or timeout, a parse failure on data the format's own marker says is well-formed. Line: "had an opinion and failed to deliver it" (log/raise) vs "never had this data" (quiet). Why: rationale.
- Boy Scout Rule: leave the codebase cleaner than you found it; refactor proactively during small changes.
- Prevent monoliths: extract cohesive pieces into modules/subpackages early (
parser.py,__main__.py); obey the ~400-500 line soft limit, split before it is a problem. - Consolidate patterns: extract duplicated logic into shared utilities, constants or decorators immediately.
- Reuse/dedup is a defensiveness strategy: two independent implementations of one operation (a hash, a type check, a message string, a cascade rule) can silently disagree later; reuse the shared helper even when the duplicate is small, to make drift structurally impossible.
Failure modes that recurred across subsystems; check for them by name. Detail (war stories, PRs, code shapes) is in recurring-bug-patterns.md (data and semantics), recurring-bug-patterns-platform.md (platform, concurrency, test harness, CI) and recurring-bug-patterns-review-rounds.md (PR #300, #305, #306 and path-key rounds): read them before extending or citing a one-liner.
Data and semantics:
None/absent vs[]/{}(empty-but-present) are different signals: absent = try the next source; empty-but-real = authoritative, stop. Watch truthiness checks,dict.get(k, []), provenance gated on the resolved value not the raw key, presence lost across a merge, a fix applied to one field but not its siblings.- Compare identifiers the ecosystem's way: PEP 503 names, PEP 440 versions (never SemVer:
"1.0"=="1.0.0"), in code, docstrings and lock-file conflict detection. - A placeholder (
UNKNOWN,N/A) is not a claim: keep it weak in a cascade (skip while a later source may know better, emit with provenance only if nothing better); a real "none" ends it; held state is per item. - A source that can resolve to zero entries needs its own "is this really a file of this format" check in a priority cascade.
- A presence-only check misfires when a key is genuinely boolean (
Pipfile.lock"editable": false). dict.get(key, default)guards absence, not a wrong type:isinstance-check values from external files (@graphpresent but not a list).- A parse helper catching only the format's exception (
TOMLDecodeError,JSONDecodeError) can still crash on a bareUnicodeDecodeError. json.loads(bytes)strips a UTF-8 BOM;json.loads(str)after.decode()raises on it: pass raw bytes where a binary parse matches.- Picking one candidate from an unordered collection needs an explicit stable tie-break; choose a merge survivor from counts over all inputs (load all, rank, prove reversed input order gives the same ids).
- A dedup/conflict-exclusion fix must cover every path that can produce the same identity; a conflict branch must skip itself, not the whole cascade.
- A guard before a side-effecting step drops that effect (a skip
returnbeforereserve_id()re-minted the id): do the effect first. - A fix scoped to "explicit input" needs an explicit flag (an unreadable-root
ERRORalso hit the implicit default roots). - A field added to a dataclass must be set by every constructor call site, from one shared helper.
- An opt-out flag that skips filling an accumulator breaks emptiness/exit checks keyed on it: re-derive from a still-populated proxy.
- Removing a read to save cost can remove an error-surfacing side effect (the
PermissionErrora full read raised): use a cheap probe. - A ruling that reverses a reading needs a before/after sweep: run a probe corpus through HEAD and the tree; list inputs that yielded records before and none now.
- Narrowing a derived field's rule needs an inventory of its writers (builder, SDK, fragment, hand-built model): run each through the new rule; derive from the value's shape as well as its key (
7Bis no count). A reversed rule is recorded as "tried, reverted". - A doc claim "after X, every Y has Z" names the code path deriving Z and the Y it reaches.
- A doc/docstring claim about "how this mechanism decides" is re-verified against the code before it is trusted or restated.
- Measure the real tools before designing a reader (
uv export,pip lockwrite no PEP 751 extras keys): probe each backend, put the matrix in the working doc. - A failing manual repro is first suspect for a wrong replication: use the real call shape (a parsed object, not a raw
str). Path.relative_todoes not collapse..:normpathlexically first,realpathonly as fallback.- An alias rule must test that the shorter key names no real file: else two real files with equal bytes (empty
__init__.py) alias; look up a few built candidate keys, never scan the table. - Write the key the lookup used, not the element's name; a typed path takes its on-disk spelling, a derived one is used as given, both through one helper.
Code shape:
- Warning/error/log wording drifts across sibling modules: one shared helper; check siblings when adding a check.
- A log message in a multi-exit function describes what that branch did, not the typical path.
- Reusing a helper outside its contract narrows behaviour silently: read its docstring preconditions, not only the return type. A shared helper's return type should fit every caller.
- A private third-party API (
obj._attr) has no structural guarantee: verify its shape interactively, prefer the public algorithm. Do not reach into another module's_name: promote a public accessor. - A circular-import claim needs a live interpreter test (both import orders).
- A guard for one direction of a symmetric hazard leaves the mirror open (write-write vs write-read deadlock): enumerate both; reproduce a concurrency claim with a timeout-bounded run, never reason it through.
- A context manager whose
__exit__can be interrupted resets shared state in__enter__(fresh container, identity check, no lock). - A version floor asserted in many places drifts and no script checks it: keep it at the verified minimum; on change grep every spelling (requirement/example move; history stays).
- An undocumented third-party breaking change can be survived without an upper pin: detect its runtime shape (
len(Cls.__parameters__)) plus aTYPE_CHECKINGbranch. - Compare a linter's error count to HEAD with the same interpreter (
pyrefly --python-interpreter).
Tests, platform and CI:
- A fixture/mock of an external system is broadened with what production inspects.
- A byte-identity test must not let the writer stamp the clock:
ZipFile.writestr("name", ...)writestime.time()(green locally, red once on CI, #306); useZipInfo(fixed 1980 date), run on a clock that moves every call. A rerun cannot prove a flake gone: perturb the variance source. Grep fixture builders forwritestr(<str>, mtime,uuid4. - For
A and Bin a derived rule, one test where onlyAfails and one where onlyBfails; mutation testing finds the missing one. - Mutation testing lies two ways: an equivalent mutant proves nothing (reinstate the exact pre-fix shape); an assertion on a lazily installed side effect observes nothing.
- A test helper that normalises what it captures hides the bug class (
subprocess.run(text=True)turns CRLF into LF): decode bytes yourself; mutation-test shell/CI changes. - A test reaching the network via a subprocess/library (
spdx3-validatefetches its schema) bypasses the socket block: mark itnetwork, route throughtests/_network.py. - Patching a function on a shared stdlib module (
mod.time.sleep) patches it for every thread: import the name into the module under test and patch that. - A logging handler bound to
sys.stderrundercapsysoutlives the capture stream:tests/conftest.pyrestores thepitloomlogger after each test; new process-global state a test mutates needs the same. - A long
parametrizevalue becomes the test id (WindowsPYTEST_CURRENT_TESTcap 32,767 chars): shortids=; conftest fails ids over 1,000. - A
skipifcondition runs at import time on every platform (os.geteuid()fails collection on Windows): use one short-circuited module constant; same for POSIX-only names in decorators andparametrize. - A hardcoded POSIX path in a test skips the "absolute" branch on Windows: build it from
tempfile.gettempdir(). - A bit-for-bit POSIX permission assertion is unwinnable on Windows: spy
os.chmodarguments; keep the real assertion on POSIX. Path.write_text("x\n")writes CRLF on Windows:write_byteswhen a digest is asserted.Path.exists()/is_file()swallow only some errnos (PermissionErrorpropagates on 3.10-3.13,Falseon 3.14): useos.path.isfile/isdirfor "never raises"; gate a test by version, plus a version-independent probe.- A textual "under the project" check misses on a case-insensitive file system: fall back to a
(st_dev, st_ino)ancestor match (st_ino == 0= no identity). - Coverage counts one leg (Linux, Python 3.14): a branch only macOS/Windows/another Python reaches (NUL in source:
ValueError<=3.11,SyntaxError3.12+) shows as missing there; cover eachexceptportably. - Fail-fast CI hides other legs: read which jobs were cancelled before concluding.
- GitHub Actions substitutes
${{ }}even inrun:shell comments: put such examples aboveenv:, not insiderun: |. - pip's
--no-build-isolationis invocation-global: do not mix a self-referential local install with unrelated packages in onepip install. - A Markdown line starting with
<tag>is an HTML block, even inside a code span that continues from the previous line (the page vanished): never start a wrapped code-span line with<. - Parallel agents: fence file ownership in writing, tell running agents when a rule is reversed, gate after all land, clean stray files and
pytest-of-*dirs.
Unix philosophy: consistent, predictable, parseable.
- Default: line-delimited, one data point per line. A data point is one record/entity, not one field: a record with several attributes keeps its
KEY=VALUEpairs on one space-separated line (PATH=... ROLE=... REQUIRED=... EXISTS=...); split across lines only for more than one record (one line per record, e.g. per configured fragment). - Stdout is data only (
--help/--versionexcepted):KEY=VALUElines frompitloom.cli.kv_output.print_kv(), or the SBOM itself for-o -(_sbom_io.write_stdout_lf()); counts, summaries, hints and-vdetails areINFO:on stderr. A value that may hold a space goes last. KEY=VALUE: uppercase KEY, no spaces around=; several pairs on one line only for one data point (e.g.FORMAT=%s FILE=%s: ...for one per-model-file warning).- Three levels reach stderr, each line starting with exactly one of
ERROR:,WARNING:,INFO:plus a short description. Nothing else is grep-able: no second competing tag, no untagged continuation line.ERROR:-- via the CLI's ownprint(..., file=sys.stderr)(cli_error_handlerincli/commands/utils.pyadds it around any raised exception); fatal to the operation.WARNING:-- a "no silent deviations" decision or a recovered non-fatal problem;logging.warning()gets the prefix automatically.INFO:-- a normal status update (e.g. "SBOM generation skipped: hook disabled");logging.info()gets it automatically.logging.debug()is developer-only: never prefixed, never on stderr normally.pitloom.logging_config.configure_logging()wires all three identically; every CLI command, the hook and every public library-API entry point must call it first. A new entry point that skips it is a bug.
- Within one subsystem use a shared literal sub-prefix (
ID registry: ...;FORMAT=%s FILE=%s: ...) and match a sibling's wording before inventing one. No global schema across unrelated subsystems; theLEVEL:tag is the one prefix every consumer keys on. - Trim messages to essentials.
- Min version 3.10: no later syntax/features unless via
__future__; noA | Bunions outsideTYPE_CHECKINGblocks. - Verify types with mypy (strict=true); pyright/pytype for second opinions.
- Untyped third-party dependency: before an
ignore_missing_importsoverride (mypy, pyrefly, ...), look on PyPI for a trusted typeshedtypes-<package>stub package and add it to thetypecheckgroup; suppress only when none exists. Why: rationale. - Fully qualified names in docstrings for non-stdlib types (
numpy.ndarray). - No
assertin production (tests only). No wildcard imports. All config inpyproject.tomlwhere possible. - Import order: stdlib -> third-party -> local, alphabetical within each; do not reorder imports that a comment says must stay in order (circular import/init).
ruff check --fix --select Iautomates it. - Type completeness: annotate all visible class/instance vars, methods, params and returns; give generic bases type args (except simple literals, enum members, standard dunders).
- Must work on Windows, macOS and Linux. Use
pathlib.Pathfor paths./tmp/and POSIX directories do not exist on Windows. - Dry-run Windows when you cannot run it: read each new path test as Windows would:
/in an expected path (Windows shows\),Path ==on names differing only in case, askipif/constant naming a POSIX-only API,..collapsed by Win32 before the open, a symlink needing privilege. Emulate withPureWindowsPath/ntpath; ask it in every review agent's brief (macOS green is not Windows green, #280).
Run and fix all errors before committing:
ruff format
ruff check --fix
pylint examples/ src/ tests/ tests/*/
mypy
pyright
pyrefly check
bandit -r
flake8- pylint needs
tests/*/too (it does not descend into subfolders without__init__.py); pass directories, not single files (a file loses its dotted name: false E0402). - Avoid ambiguous variable names (E741). Max line length 88 (prefer 80). Remove unused imports and trailing whitespace. Non-ASCII only in human-language messages and diagrams.
- Complexity targets: Returns<=6, Args<=5, Locals<=15, Nesting<=5, Branches<=20, Statements<=80, McCabe<=10, Cognitive<=15. Enforced ceilings in
pyproject.toml/.flake8are interim ratchets (max-args=6,max-locals=18, McCabe=35, Cognitive=60); backlog:working-docs/design/complexity-and-file-size-roadmap.md. - Pylint suppressions go on the preceding line as
# pylint: disable-next=<msg>(a bare# pylint: disable=before anexceptstopped working in pylint 4.1).
Source files carry SPDX tags in this order:
SPDX-FileCopyrightText: <year> <name>
SPDX-FileType: SOURCE # or DOCUMENTATION
SPDX-License-Identifier: Apache-2.0 # or CC0-1.0 for docs
working-docs/ standalone docs add Created and Last-Modified (YYYY-MM-DD). SKILL.md files use # YAML comments for these headers at the block's top.
- Bug fixes and regressions: add a regression test that fails without the fix and passes with it.
- Guard against vacuous passes: in a "changed input -> unchanged output" test, assert the input actually changed.
- Use pytest patterns,
spec/autospecfor mocks,@pytest.mark.parametrize. - Keep tests compact: same coverage and adversarial nature, no redundancy; check whether an existing test hits the branch, fold near-duplicates into one
parametrize(a case per boundary), share fixtures; never drop the only case killing a mutant. - Structure: same file size limits as source. Naming
test_<area>.py, 1:1 with the source module (disambiguate tail clashes). Group tests in same-named subfolders mirroringsrc/pitloom/<package>/at 3+ related files (no__init__.py). - No non-deterministic assertions: no real wall-clock time, unseeded random/UUID, or set/dict iteration order; freeze time, mock the source or sort. Elapsed-duration bounds (e.g.
time.monotonic()deltas) with a generous limit are fine. - Don't couple tests to undocumented internals:
raises(match=...)and log assertions target wording the source documents as intentional (a short stable substring); SPDX3 field/type assertions come from the spec (spdx_python_model.bindings), not Pitloom's own layout. - Runtime warnings are test failures (
filterwarnings = ["error"]): an unavoidable third-party warning gets a narrow module/category-qualifiedignore::entry with a comment saying why, never a blanket one. - Drift-guard test for two independent implementations of one operation: don't merge them just to dedup; assert both give the same result on one shared fixture.
- Adversarial edge cases pay most on tri-state (None/empty/absent) and cascade/tie-break logic: per new field or merge path, one test per boundary (declared-but-empty vs absent, spec-equivalent spellings, candidates needing a tie-break, malformed/truncated input).
- Test tiers:
- Tier 1, editing: touched tests only (
pytest tests/<area> -k ...,--lf), ruff, mypy. - Tier 2, each review/fix round: full suite (xdist), every CI linter (pyrefly too), each new regression test failing on the base tree, branch coverage of changed files, the manual CLI checks for the touched area.
- Tier 3, once per PR before merge: mutation testing (each mutant runs only its module's tests), the full manual CLI checks (before handoff, after merging main), the parity/determinism harness, the Windows dry-run, one fresh-context sweep, a fresh-eyes read of any changed
SKILL.md.
- Tier 1, editing: touched tests only (
pytest runs in-process; it misses the loom entry point, argv parsing, real filesystem/archive I/O and CLI/library/hook/skills drift. .venv/bin/python scripts/manual_cli_checks runs numbered checks (determinism, surface parity, round trips, registry, fragment merge, offline, --allow-build, refused wheels, AI model outcomes), the CLI matrix (subcommand x option x env var) and command sequences in a scratch dir; --only takes ids/globs, --network adds network ones. When to run:
- docs-, tests- or
working-docs/-only change: skip; - each round touching
assemble/,extract/,core/,embed.py,__main__.pyorplugins/hatch.py: the checks for that area via--only(manual-cli-checks.md); - before handoff, and again after merging main: the full run (
-j 8).
A new CLI option or subcommand needs an entry in scripts/manual_cli_checks/_matrix_plan.py (M/completeness fails in CI otherwise).
- Account for GNU/BSD/macOS tool differences; defensive variable expansion; quote paths and variables.
- Composite-action scripts (
scripts/action/, tested bytests/scripts/action/): CI lints onlyexamples/ src/ tests/; run the linters,shellcheck -xandactionlintonscripts/by hand. Notes: github-action.md. - Windows Git Bash / macOS bash 3.2: native Windows Python ends lines with CR that
$(...)keeps; Git Bash has no/dev/stderr; bash 3.2 has noreadarrayand is quadratic on${x//pat/}. NormaliseGITHUB_ACTION_PATHbackslashes; keep*.shandaction.ymlLF (.gitattributes). - Never let a step log tool output raw: a
::line runs as a workflow command. Fence the echo with::stop-commands::<random token>; fence, echo and annotations must share one stream (the runner does not order stdout against stderr).
- Python module leading underscore (
_common.py) when nothing outside its package directory imports it; none (wheel.py) when something does. - Consult Schema.org, NIEM Model, FIBO and OBO Foundry for ontology naming.
- SPDX 3 JSON: canonical serialisation and RFC 8785. JSON-LD: RDF canonicalization. Dates: ISO 8601, UTC+0.
spdx3vocabulary terms are plainstr:spdx3.RelationshipType/RelationshipCompletenessmembers arestr-valued NamedIndividual IRIs, not enum instances. Type a parameter asstr, never as the class (spdx3.RelationshipCompleteness | Nonefails mypy).
- British English for docs, comments and text. American spelling only for code identifiers and for field, key, type or file-format names a specification fixes exactly (
license,licenseConcluded,Organization,graph.initializer). - IETF verbal forms (RFC 2119/8174) for internet/web/semantic-web projects; ISO verbal forms for SPDX docs.
- Code comments are direct, concise and about the current implementation; no history. Legitimate current behaviour vs an alternative design is fine.
- PR text defaults (when a request gives no style or length; an explicit limit overrides): title <= 60 chars, no markdown; summary <= 280 chars, concise bullets, markdown allowed; description <= 600 chars, structured, with a short rationale. Count characters before presenting; each in its own fenced code block, copy-paste ready.
Ask before doing: large cross-package refactors; new dependencies with broad impact; destructive data or migration changes.
Never: edit generated files by hand when a generation workflow exists; use destructive git operations unless explicitly requested; auto-commit (leave all changes uncommitted for the user to review and commit, unless they explicitly ask).