Skip to content

feat: read requirements.txt contents to judge pinning (feature 037) - #443

Merged
Marc-cn merged 2 commits into
darnitdevorg:mainfrom
mlieberman85:037-pinned-requirements-detection
Sep 20, 2026
Merged

Marc-cn merged 2 commits into
darnitdevorg:mainfrom
mlieberman85:037-pinned-requirements-detection

Conversation

@mlieberman85

@mlieberman85 mlieberman85 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Closes #429.

repro_deps_pinned judged dependency pinning by filename and never opened the files it judged. A requirements.txt produced by pip-compile --generate-hashes received the same hard FAIL as one containing a bare numpy. Reported by @Jaydeep869 while evaluating darnit-reproducibility against real repositories.

What the control does now

When no lock file is present, it reads the file.

requirements.txt Verdict Message says
every line == + --hash= PASS pinning was confirmed by reading the file
every line ==, no hashes WARN transitive dependencies resolve at install time
any open specifier FAIL names an offending requirement
unreadable, undecodable, unresolved -r FAIL contents could not be inspected
empty or comments-only treated as absent --

Lock files still decide first and their contents are never consulted.

The WARN tier has a cost, and it is intentional

Because WARN counts as FAIL for compliance arithmetic (Principle II), a project that pins every direct dependency still cannot reach compliance on this control without adopting a lock file. Its transitive dependencies resolve at install time, so the build is not reproducible and the control cannot honestly assert that it is. For a framework whose subject is reproducibility that is the correct message rather than an unfortunate side effect, but it will generate pushback from users who feel they have done the work.

A commit-SHA VCS reference is exactly identified but is never hash evidence, so it caps a file at WARN. pip cannot enforce hashes on a git reference, so a file containing one is not a require-hashes file and must not borrow that tier's PASS.

Framework change: HandlerResultStatus gains WARN

This is the part worth reviewer attention, because it changes what any handler is allowed to conclude.

A handler could report PASS, FAIL, INCONCLUSIVE or ERROR. One that had read the evidence and found it insufficient had to return INCONCLUSIVE, which sends the pipeline onward and eventually produces a WARN carrying the fixed string "Could not automatically verify - manual verification required". The operator then reads, about a file the tool parsed successfully, that the tool could not verify it -- the same confusion #427 was about, arriving by a different route.

WARN is now conclusive and carries the handler's own message. Gated by the RFC-0001 Stage 1 authority rule exactly as PASS and FAIL are:

Handler outcome Terminal authority Non-terminal, not last Non-terminal, last step
PASS CONCLUDE_PASS ATTACH_AND_CONTINUE TERMINATE_INCONCLUSIVE
FAIL CONCLUDE_FAIL ATTACH_AND_CONTINUE TERMINATE_INCONCLUSIVE
WARN CONCLUDE_WARN ATTACH_AND_CONTINUE TERMINATE_INCONCLUSIVE
INCONCLUSIVE ATTACH_AND_CONTINUE ATTACH_AND_CONTINUE TERMINATE_INCONCLUSIVE

The WARN row is the PASS/FAIL row. A suggestive step cannot manufacture a WARN that halts verification any more than it can manufacture a PASS. INCONCLUSIVE behaviour is unchanged, and the all-inconclusive fallthrough still produces its WARN for controls that exhaust their passes.

docs/architecture/framework-design.md documents the outcome and the table, per Development Workflow item 3. Note that validate_sync.py cannot check this -- validate_pass_types_sync greps a hardcoded list of seven handler names against builtin_handlers.py, so it passes regardless. A test asserts the documentation instead.

Verification

  • SC-008 (no existing control's status changes): 213 controls across 6 framework/repo pairs are captured twice in the same environment -- once normally, once with the WARN outcome routed the way it was routed before this change -- and the two agree exactly.

    A committed golden was tried first and is worth mentioning because it failed in CI while passing locally. Seven OSPS controls resolve FAIL on a developer machine and WARN on a GitHub runner, so a golden of absolute control statuses pins the environment as much as the code. The differential capture has no such problem: whatever the environment does, it does identically to both runs and cancels.

  • SC-004 / SC-005 (lock-file and no-dependency paths byte-identical, including confidence): same ordering.

  • 3207 tests pass, ruff check . clean, validate_sync.py passes.

Two bugs found along the way, filed and not fixed here

#442 -- controls leak across frameworks within a process. A reproducibility audit returns 5 controls in a clean process and 71 (65 of them OSPS baseline) if an openssf-baseline audit ran earlier in the same process. The MCP server is long-lived, so this affects the product surface, not just tests. It surfaced because the in-process baseline capture reported 46% of the corpus as nondeterministic -- none of it flaky, all of it this leak. baseline_capture.py runs each pair in a subprocess, which is a workaround rather than a fix.

#441 -- darnit-core imports packaging at runtime without declaring it. composition.py:363 and framework_schema.py:1445; uv pip show packaging reports Required-by: pytest. This PR declares packaging for darnit-reproducibility's own use, which does not cover core's call sites.

Also worth knowing: run_checks writes a .project/ directory into the repository it audits. The baseline capture copies fixtures into scratch space before auditing for that reason -- auditing them in place leaves untracked files in the tree, which is what broke CI on #435.

Scope held deliberately

File discovery is unchanged (root requirements.txt only -- requirements-dev.txt and requirements/base.txt stay undiscovered), other loose manifests are still judged by presence alone, confidence stays 0.8 everywhere, and -r/-c includes are not followed. Each is a requirement with a test asserting the non-change.

Stacked on #439

This branch is built on fix-plugin-handler-registration (#427, #428), which is still open, so its commit appears here until that merges. Rebasing off it is not an option: the committed SC-008 baseline was captured with it present, and without it repro_deps_pinned does not execute via the CLI path at all. Merge #439 first.

…itdevorg#427, darnitdevorg#428)

Both reported by @Jaydeep869 against darnit-reproducibility; both root
causes are in the framework.

## darnitdevorg#427 -- plugin handlers never registered outside the MCP path

`darnit audit --framework reproducibility` emitted "handler
'repro_deps_pinned' not found in registry" for all five of the plugin's
controls, which then fell through to `manual` and reported WARN. The
WARN text is "Could not automatically verify - manual verification
required" -- indistinguishable from a control that genuinely could not
be determined, which is why this read as working software.

Three separate defects had to be fixed:

1. `cmd_audit` resolved the framework for loading controls but never
   passed `framework_name` to `run_sieve_audit`, so the driver had no
   idea which framework it was running. openssf-baseline masked this
   because its controls use only built-in handlers, which need no
   plugin registration.

2. `impl.register_handlers()` was called in exactly one place --
   `server/factory.py`, the MCP server path. Every other entry point
   got no plugin handlers. Added
   `core.discovery.register_implementation_handlers()` and called it
   from `run_sieve_audit`, which is the shared driver both the CLI and
   the MCP audit tool go through. It runs regardless of whether
   `controls` was supplied, since a caller passing pre-loaded specs
   still needs the handlers those specs reference.

3. The two in-tree plugins that ship custom handlers implement
   `register_sieve_handlers()`, not the `register_handlers()` that
   CLAUDE.md documents and that darnit-baseline implements. They worked
   only because their `register()` entry point self-registers during
   discovery -- and discovery is cached, so any caller that warmed the
   cache earlier in the process left the handlers unregistered. That
   timing dependence is why the bug presented as 3-of-5 handlers
   missing on one run and 5-of-5 on the next. The new helper accepts
   either spelling so registration is explicit and cache-independent.

The reporter's proposed fix (calling `discover_implementations()` in
`cmd_audit`) addresses the symptom but not the cause: discovery does
not register handlers, and relying on it re-firing `register()` is the
cache-dependent side channel above.

Reconciling the two method names across plugins is left alone here --
that touches three packages and deserves its own change. The tolerance
is pinned by tests so it stays deliberate rather than accidental.

## darnitdevorg#428 -- non-deterministic control order

`merge_configs` collected control IDs into a `set`, so iteration order
was randomized per PYTHONHASHSEED and the same repo audited twice
listed its controls differently. Five consecutive runs produced five
orderings.

Deduped through `dict.fromkeys` instead. `dict` keys already carry TOML
declaration order, which groups controls by domain the way the
framework author wrote them; sorting would have been stable too but
would have discarded that grouping.

## Verification

Before: 5 handlers missing, order differed across all 5 runs.
After: 0 handlers missing across 3 runs, order stable, and RE-01.01
returns a real verdict for the first time via CLI.

13 new tests. Full suite: 2918 passed, 14 skipped.

Note on the test command: `--ignore=tests/darnit/parity` skips parity
tier 1, which CI runs. The correct local invocation is
`--ignore=tests/darnit/parity/tier2` -- tier 2 is manual-dispatch and
needs API keys, tier 1 runs on every PR.

Signed-off-by: Michael Lieberman <mlieberman85@gmail.com>
@mlieberman85
mlieberman85 force-pushed the 037-pinned-requirements-detection branch from e133d56 to e0e8e20 Compare September 17, 2026 21:12
…arnitdevorg#429)

`repro_deps_pinned` judged dependency pinning by filename and never opened
the files it judged. A `requirements.txt` produced by
`pip-compile --generate-hashes` received the same hard FAIL as one
containing a bare `numpy`, with a message ("Dependency manifests found but
no lock files") that was accurate about what the check looked at and
misleading about what it concluded.

The control now reads the file when no lock file is present:

  hash-pinned    every requirement `==` plus a hash   PASS
  version-pinned every requirement `==`, no hashes    WARN
  unpinned       any open specifier                   FAIL, names an offender
  not-inspectable unreadable, undecodable, or an
                 unresolved `-r` include              FAIL, says so

WARN rather than PASS for the version-pinned case is deliberate and has a
cost worth stating: because WARN counts as FAIL for compliance arithmetic
(Principle II), a project that pins every direct dependency still cannot
reach compliance on this control without a lock file. Its transitive
dependencies resolve at install time, so the build is not reproducible and
the control cannot honestly assert that it is.

A commit-SHA VCS reference is exactly identified but is never hash
evidence, so it caps a file at WARN. pip cannot enforce hashes on a git
reference, so a file containing one is not a require-hashes file and must
not borrow that tier's justification.

Framework change: HandlerResultStatus gains WARN
------------------------------------------------

A handler could report PASS, FAIL, INCONCLUSIVE or ERROR. One that had
read the evidence and found it insufficient had to return INCONCLUSIVE,
which sends the pipeline onward and eventually produces a WARN carrying
the fixed string "Could not automatically verify - manual verification
required". The operator then reads, about a file the tool parsed
successfully, that the tool could not verify it.

WARN is now a conclusive outcome carrying the handler's own message. It is
gated by the RFC-0001 Stage 1 authority rule exactly as PASS and FAIL are:
a suggestive step cannot manufacture a WARN that halts verification any
more than it can manufacture a PASS. INCONCLUSIVE behaviour is unchanged,
and the all-inconclusive fallthrough still produces its WARN for controls
that exhaust their passes.

No existing handler returns the new outcome. Verified mechanically rather
than argued: 213 controls across 6 framework/repo pairs are captured twice in
the same environment, once normally and once with the WARN outcome routed the
way it was routed before this change, and the two agree exactly. Comparing
against a stored golden was tried first and abandoned -- control statuses
depend on the environment as much as on the code, so a golden pins the runner
rather than the change.

`packaging` is declared by darnit-reproducibility rather than inherited.
darnit-core imports it at runtime in two places without declaring it
anywhere; that is filed as darnitdevorg#441 and not fixed here.

Scope held deliberately: file discovery is unchanged (root
`requirements.txt` only), other loose manifests are still judged by
presence, and confidence stays 0.8 everywhere.

Closes darnitdevorg#429
Refs darnitdevorg#441, darnitdevorg#442

Signed-off-by: Michael Lieberman <mlieberman85@gmail.com>
@Marc-cn

Marc-cn commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Tested on Linux, after #439 merged.All five requirements.txt shapes match the table:.
Framework change: WARN routes through StepDisposition.CONCLUDE_WARN in the same mapping as PASS/FAIL (orchestrator.py:83), so a suggestive step can't conclude one. 60 warn/authority tests pass.
SC-008: openssf-baseline on tqdm is byte-identical between main and this branch across all controls.
567 passed in tests/darnit_reproducibility + tests/darnit/sieve.
LGTM

@Marc-cn
Marc-cn merged commit 4364659 into darnitdevorg:main Sep 20, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

repro_deps_pinned unconditionally flags exact-pinned requirements.txt as a loose manifest

2 participants