From 7da2b0f8d8738b767eca28574e8b7492c5f528fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Miralles=20Ranca=C3=B1o?= <68760931+FlagshipDev@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:28:27 +0200 Subject: [PATCH] =?UTF-8?q?0.1.0a8=20=E2=80=94=20the=20dependency=20signal?= =?UTF-8?q?,=20and=20what=20it=20refuses=20to=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What this release makes reachable that was not before. ## A second signal Hullwork verifies which of the things your tools claim are actually true. Until now it did that for one signal: a production error becomes a failing test, then a fix, then a draft pull request. This release adds the second. `hullwork deps --checkout .` reads the versions your lock files pin, asks OSV what is published against those exact versions, and prints what came back. No credential of any kind — no forge, no model, no database. The one host it contacts takes no key and no account. `--verify` then answers the question nobody else does: it applies each published fix in a sandbox and runs **your own test suite** against it. The verdict is ranked by what it asks of a person — fix your suite first, needs work, blocked, ready to take — worst first. `--open` opens a draft pull request for the ones that passed, one per package, never a batch, and never anything else. `--fix` asks an agent to change your code so an upgrade that breaks your suite fits, without being allowed to touch your dependency files. ## What it refuses to claim, which is most of the point The claim on a verified upgrade is exact: *your suite passed before this change and passes after it.* Never "safe", never "this fixes the vulnerability". A suite that does not exercise the dependency stays green without ever loading the new version, and the pull request says so. And it says **no** more often than yes. Measured on four real third-party repositories before this release: one clean verdict. The rest could not be measured at all — a suite that reaches the network a sandbox denies by design, a pin in a file the image does not install from, an image the project brings ready-made. Each is reported as what it is, with the reason and what to change, rather than verified wrongly. ## `hullwork features` New, and it answers before you have decided anything: feature by feature, whether this can serve your project, which requirement is missing when it cannot, and **what it will not do even when it can**. No credential, no container, no socket, nothing written. ## The Sentry webhook route Enabled. Authenticated by the token in its URL — the same credential a GlitchTip route has, checked the same way, because GlitchTip cannot sign its webhooks at all. **Sentry's signature is not verified**, and `SECURITY.md` says once, for both providers, what that does not cover: anyone who obtains the URL can post to it. Treat it as a secret; `hullwork projects rotate-secret` replaces it. ## A number about itself `hullwork status` and the page now report what arrived and how much of it left a person's desk with evidence attached — including a row for what Hullwork **put on** that desk rather than took off it. That row is the one this product can be embarrassed by, and it is printed above the others. ## One new manifest field `autofix.open_upgrades`, false by default. Opening a verified upgrade as a pull request is the first thing a project can refuse while Hullwork is able to do it: having a capability is not the same as having agreed to it. Every other default in that block already refuses. Note that a manifest carrying this field is rejected by earlier releases, which forbid unknown fields by design. A project that does not set it is unaffected. ## Fixed - A verified-green verdict could be produced for a version that was never installed — twice, by two different routes: a pin in a file outside `runtime.dependencies`, and `install: none`, which is the default. Both are now refused before a container is built, with the reason and the remedy. - A fix phase that **created** test configuration where a project had none could switch the suite off and publish with the mechanism inside its own diff. The guard against editing that configuration had never covered inventing it. - `deps --verify` did not hand the source to the image build, so any project whose installer reads its own files could not be verified at all. - Requirements files outside a root `requirements.txt` were not read: `requirements/prod.txt`, `requirements-dev.txt` and their shape were invisible, and the report said nothing about the gap. - Agent runs discovered a missing model credential, and a missing gateway image, at the most expensive possible moment. Both are answered in under a second now. ## Still true, and worth reading before relying on any of it Nobody outside this project has installed Hullwork. Every measurement in this release was taken by the people who wrote it. `docs/status.md` is the honest scope and it changes weekly. Signed-off-by: Javier Miralles Rancaño <68760931+FlagshipDev@users.noreply.github.com> --- README.md | 39 +- SECURITY.md | 12 + docs/hullwork-yml.md | 23 + docs/status.md | 32 +- docs/what-hullwork-is.md | 112 ++ hullwork.yml | 6 +- hullwork/__init__.py | 2 +- hullwork/bump.py | 528 ++++++++++ hullwork/cli.py | 912 +++++++++++++++- hullwork/config.py | 20 +- hullwork/dependencies.py | 231 +++++ hullwork/dispatch.py | 330 +++++- hullwork/engine.py | 24 + hullwork/evidence.py | 126 ++- hullwork/features.py | 369 +++++++ hullwork/forge/__init__.py | 6 +- hullwork/forge/factory.py | 6 +- hullwork/forge/forgejo.py | 2 +- hullwork/manifest.py | 25 +- hullwork/osv.py | 195 ++++ hullwork/outcomes.py | 171 +++ hullwork/page.py | 15 +- hullwork/propose.py | 133 ++- hullwork/refit.py | 406 ++++++++ hullwork/resolve.py | 280 +++++ hullwork/sandbox/net.py | 45 + hullwork/sandbox/run.py | 38 +- hullwork/trial.py | 11 + hullwork/upgrades.py | 220 ++++ hullwork/upstream.py | 2 +- hullwork/webhooks.py | 40 +- hullwork/work.py | 73 +- images/banner.svg | 21 +- images/social-preview.png | Bin 66643 -> 78223 bytes images/social-preview.svg | 77 ++ images/the-pipeline.svg | 5 +- pyproject.toml | 8 +- tests/test_bump.py | 563 ++++++++++ tests/test_dependencies.py | 627 +++++++++++ tests/test_dispatch.py | 52 + tests/test_forge_code.py | 5 +- tests/test_propose.py | 197 ++++ tests/test_refit.py | 973 ++++++++++++++++++ tests/test_resolve.py | 169 +++ tests/test_the_desk_it_cleared.py | 294 ++++++ ...tation_describes_the_published_artefact.py | 55 + tests/test_upgrades.py | 616 +++++++++++ tests/test_webhooks.py | 91 +- tests/test_what_it_can_do_for_you.py | 323 ++++++ tests/test_what_the_closed_items_owed.py | 6 +- 50 files changed, 8412 insertions(+), 104 deletions(-) create mode 100644 docs/what-hullwork-is.md create mode 100644 hullwork/bump.py create mode 100644 hullwork/dependencies.py create mode 100644 hullwork/features.py create mode 100644 hullwork/osv.py create mode 100644 hullwork/refit.py create mode 100644 hullwork/resolve.py create mode 100644 hullwork/upgrades.py create mode 100644 images/social-preview.svg create mode 100644 tests/test_bump.py create mode 100644 tests/test_dependencies.py create mode 100644 tests/test_refit.py create mode 100644 tests/test_resolve.py create mode 100644 tests/test_the_desk_it_cleared.py create mode 100644 tests/test_upgrades.py create mode 100644 tests/test_what_it_can_do_for_you.py diff --git a/README.md b/README.md index 43850e0..caa9c3b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ -![Hullwork — from production errors to reviewable draft pull requests. Self-hosted, with your forge, -your error tracker, your model endpoint and a human gate on every merge.](images/banner.svg) +![Hullwork — verifies which of your tools' claims are actually true, before a person is asked. +Self-hosted, with your forge, your error tracker, your model endpoint and a human gate on every +merge.](images/banner.svg) [![CI](https://github.com/easybytehub/hullwork/actions/workflows/ci.yml/badge.svg)](https://github.com/easybytehub/hullwork/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/easybytehub/hullwork?include_prereleases&label=release&color=blue)](https://github.com/easybytehub/hullwork/releases) @@ -7,9 +8,13 @@ your error tracker, your model endpoint and a human gate on every merge.](images [![Python](https://img.shields.io/badge/python-3.12-blue?logo=python&logoColor=white)](pyproject.toml) [![Licence: FSL-1.1-ALv2](https://img.shields.io/badge/licence-FSL--1.1--ALv2-blue)](LICENSE.md) -**Pre-alpha.** Both halves run end to end, five attempts have reached a draft pull request, and nobody -outside this project has installed it. What works, what does not, and what nobody has demonstrated are -all in **[docs/status.md](docs/status.md)** — read that before relying on any of this. +**Hullwork verifies which of the things your tools claim are actually true, before a person is +asked** — errors, dependencies, incidents. What that means, and everything that follows from it, is +**[what Hullwork is](docs/what-hullwork-is.md)**. + +**Pre-alpha.** The error signal runs end to end, five attempts have reached a draft pull request, and +nobody outside this project has installed it. What works, what does not, and what nobody has +demonstrated are all in **[docs/status.md](docs/status.md)** — read that before relying on any of this. > **What this repository is.** All of Hullwork, under a source-available licence that becomes > Apache-2.0 two years after each release: the whole loop, uncapped, for as many projects as you like. @@ -35,6 +40,28 @@ all in **[docs/status.md](docs/status.md)** — read that before relying on any ## What it does +Every signal it accepts arrives from a tool that **asserts something and proves nothing**. A tracker +says *something broke*. An advisory says *this version is vulnerable*. A scanner says *this could be +exploited*. None of them ran anything. + +So Hullwork takes the claim into a sandbox, submits it to an oracle **it cannot influence**, and +returns a verdict with the run attached. The oracle changes per signal; the machine does not. + +| what arrives | what it really says | what settles it here | +|---|---|---| +| a production error | *something broke* | a test that fails before the change and passes after | +| a dependency advisory | *this version is vulnerable* | your own suite, run against the upgrade | +| a static finding | *this could be exploited* | a test naming the hostile input | + +**Only the first row is in a release.** The second is built and unreleased — it is in no image you +can pull, and this page will say so until it is. The third does not exist. + +**"I could not verify this" is a first-class answer**, and on this repository's own numbers — 160 +code scanning alerts, five real — it is the answer about nine times in ten. Delivering it honestly +is worth more than a fix, because nobody else delivers it at all. + +### The path the error signal takes + ![Two things arrive — a production error by webhook, and a human report through a normaliser. Hullwork triages, deduplicates and assigns a risk lane. Green items are attempted unattended, amber wait for your approval, red are never attempted at all; anything matching no lane is red. A green item goes to a coding @@ -146,7 +173,7 @@ instance has ever run it, which is not support. | | | | |---|---|---| | **Forges** | ✅ Forgejo · ✅ Gitea · ✅ GitHub | ⚠️ GitLab — the adapter is written, no instance has run it | -| **Error trackers** | ✅ GlitchTip, and anything posting a Sentry-compatible payload | ⚠️ Sentry's signed webhooks — the route is written and switched off, because verifying an HMAC means storing a secret reversibly and that decision has not been made | +| **Error trackers** | ✅ GlitchTip, and anything posting a Sentry-compatible payload | ⚠️ Sentry's own route is built and **not in a release yet**; when it lands it is authenticated by the token in the URL and its signature is not verified — which is what GlitchTip offers at all. [`SECURITY.md`](SECURITY.md) says what that does not cover | | **Model endpoints** | ✅ anything speaking the Anthropic or OpenAI protocol family — Anthropic and OpenRouter both exercised | Your key, your endpoint. No provider is integrated and none is privileged | | **Agents** | ✅ `claude-code`, exercised | Any container that takes a worktree and returns changed files qualifies: the agent is a contract, not an integration | | **Your stack** | ✅ any Linux image with a shell, on this instance's architecture — you name the image your CI already uses | ❌ `distroless` and `scratch`, refused at registration rather than at attempt time | diff --git a/SECURITY.md b/SECURITY.md index 9a9f07d..05a8ec9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -48,6 +48,18 @@ Each of these is a property of the code, and the file that implements it explain - **The two halves hold different credentials** (DR-0009). The half that answers webhooks — the one an attacker can reach — cannot push, and refuses to start if it finds a credential that can. The half that can push listens on nothing. +- **A webhook is authenticated by the token in its URL, and by nothing else.** That token is minted + once, shown once, and stored as a one-way hash; a wrong one is refused identically on every + provider's route, so the door cannot be used to confirm which tracker a project uses. + + **What that does not cover, stated once for both providers** — Sentry's route is built and not in + a release yet (`docs/status.md`), and this is what it will be authenticated by when it lands. + GlitchTip cannot sign its webhooks — + no header, no secret, no setting — so the URL is the credential. Sentry *can* sign, and Hullwork + does not verify it: doing so requires holding Sentry's client secret in a form it can be read back + from, which is a storage decision this project has not made. So **anyone who obtains the URL can + post to it**, whichever tracker you use. Treat it as a secret: it is in your tracker's + configuration, and `hullwork projects rotate-secret` replaces it. ### What leaves the instance diff --git a/docs/hullwork-yml.md b/docs/hullwork-yml.md index 3e05244..7fce164 100644 --- a/docs/hullwork-yml.md +++ b/docs/hullwork-yml.md @@ -258,6 +258,29 @@ incomplete. That ordering is what makes this a translation rather than a treadmi that describe an environment are about three and language-neutral, while ecosystems are about fifty and keep arriving. +**And they compose, which is easy to miss and worth a paragraph.** *Bring your own image* and +*name an installer* read as a choice, and they are not one. `base` takes any image; `install` takes +**your own command** rather than only a name from the recipe list. So a third shape has always been +legal: + +```yaml +runtime: + base: ghcr.io/acme/ci-base:2026.7 # yours, unchanged + install: "pip install -r requirements.txt" # your line, not a recipe of ours + dependencies: [requirements.txt] # the file your versions are pinned in +``` + +That is **one layer on top of the image you named**, not a rebuild from scratch — and it is what +makes a dependency upgrade measurable: an upgrade can only be checked against a suite that actually +runs it, which means the image has to be refreshed from the file that pins. With `install: none` +your image is used exactly as it comes, which is right for running tests and means a changed pin +changes nothing your suite would see. + +Nothing was added to permit this. `install` has accepted an arbitrary command since DR-0007 was +built, and this paragraph exists because nothing said so — the example at the top of this file +shows `install: none`, and a reader with an image of their own could reasonably conclude the field +was not for them. + **The one frontier that will not move**: any Linux image with a shell, on this instance's architecture. Both are checked when you register, and `distroless`/`scratch` are permanently out. diff --git a/docs/status.md b/docs/status.md index 942b041..6ee521e 100644 --- a/docs/status.md +++ b/docs/status.md @@ -4,6 +4,26 @@ Pre-alpha. This page is the honest scope, kept apart from the README so it has r so nothing in it has to be shortened to keep an introduction readable. It changes weekly; the date on each claim is part of the claim. +> **What all of this is the state of.** Hullwork verifies which of the things your tools claim are +> actually true, before a person is asked — [what Hullwork is](what-hullwork-is.md). Three signals, +> three oracles, one mechanism. This page was accurate about the halves and silent about what they +> were halves of, so here is the row that was missing (item 181, 2026-08-09): +> +> | signal | oracle | state | +> |---|---|---| +> | a production error | a test that fails first and passes after | **released**, and everything below describes it | +> | a dependency advisory | your own suite, run against the upgrade | **built and unreleased** — in no image you can pull | +> | a static finding | a test naming the hostile input | does not exist | +> +> The second row is work items 172–180. Its command is deliberately **not named here**: the guard on +> this repository's documentation refused the sentence that named it, and it was right to — a command +> a reader cannot run is an invitation to type it and be told it does not exist. What is actionable +> is the state, and the state is *not in an image you can pull*. +> +> This page will keep saying so until a release contains it: documentation describes the released +> artefact, not the working tree (`CONTRIBUTING.md`, and item 165 records the three times that rule +> was learnt the hard way). + ## What works today A production error posted by your error tracker is authenticated, stored, normalised, deduplicated, @@ -39,10 +59,14 @@ its suite runs against a blank one per phase. That path has been exercised by ** ## What does not exist yet -- **Only the GlitchTip webhook route is enabled.** Sentry signs its webhooks properly and would be - verified by HMAC — that route is written and switched off, because verifying a signature means - storing a client secret in reversible form, which is a different storage decision from the one-way - hash used here and has not been made. +- **Only the GlitchTip webhook route is enabled in a release.** Sentry's is **built and unreleased** + as of 2026-08-09 (item 189): in the working tree it is authenticated by the token in the URL — the + same credential GlitchTip has, checked the same way, because GlitchTip cannot sign at all. Its + signature is deliberately not verified; that would need Sentry's client secret held in reversible + form, which is a storage decision this project has not made. + + The image you can pull still answers `501` there, and this page will say so until a release + carries it — the same rule as the dependency signal above, and for the same reason. - **Of the notification channels, only `none` and `console` deliver.** `telegram` and `email` parse in the manifest and are refused at delivery, because a transport nobody has exercised is a transport whose first real run happens in front of a user. diff --git a/docs/what-hullwork-is.md b/docs/what-hullwork-is.md new file mode 100644 index 0000000..7284649 --- /dev/null +++ b/docs/what-hullwork-is.md @@ -0,0 +1,112 @@ +# What Hullwork is + +> **The canonical statement of the product, fixed 2026-08-09 by the operator.** Every other document +> describes a part; this one says what the parts are parts of. When a document and this page +> disagree, this page is what was decided and the other one has not caught up yet. +> +> The reasoning behind it is DR-0017. This page is +> the short form, kept separate so it can be read in a minute and quoted without a link. + +## In one sentence + +**Hullwork takes the work off a developer that nobody wants to do — errors, dependencies, +incidents — by verifying, before a person is asked, which of the things their tools claim are +actually true.** + +## Why it is not a list of features + +Every signal Hullwork accepts arrives from a tool that **asserts something and proves nothing**: + +| what arrives | from | what it really says | verified by | +|---|---|---|---| +| a production error | Sentry, GlitchTip | *something broke* | a test that reproduces it | +| a dependency advisory | Renovate, Dependabot, OSV | *this version is vulnerable* | the project's own suite | +| a static finding | CodeQL, Opengrep | *this could be exploited* | a test naming the hostile input | + +Three signals, three oracles, **one mechanism**: take the claim into a sandbox, submit it to an +oracle the agent cannot influence, return a verdict with the run attached. + +That is why these are not three features that happen to share a repository. The oracle changes; the +machine does not. + +## The three properties everything else follows from + +**No oracle is written by the agent to make itself look right.** A reproducing test must fail first +on untouched code; the project's suite belongs to the project; a hostile input has to be nameable. +Remove this and the verdicts are worth nothing. + +**"I could not verify this" is a first-class answer.** On this repository's own numbers — 160 code +scanning alerts, five real — that is the answer roughly nine times in ten, and delivering it +honestly is worth more than a fix, because nobody else delivers it at all. + +**What is measured is how much left a person's desk with evidence attached.** Not a success rate. +An instance computes its own, on its own code, from its first day. + +## What that means against the tools it sits beside + +None of them verify anything, and that is the whole position: + +- **Renovate and Dependabot** open the pull request and let the reviewer find out. Their own + documented weakness is noise — *"here is every update, you decide"* — and it is structural: they + do not execute, so they cannot rank. Hullwork runs the suite first and hands over the ones that + pass. +- **Sentry Seer and Copilot Autofix** fix from unverified claims, with the same confidence for the + five that are real and the hundred and fifty-five that are not. +- **Reachability vendors** reduce the same noise with static analysis, which is another unverified + claim about an unverified claim. Executing is more expensive and it is not arguable. + +## What Hullwork does not do, and will not + +Merge by itself (constitution principle 1). Attempt a fix without a reproducing +test (DR-0003). Match a competitor's breadth for its own sake: +depth over coverage, because a verified verdict in five ecosystems is worth more than an unverified +one in ninety. + +## Which documents have caught up, and when + +Recorded so this page can be checked rather than believed. **Kept as a record rather than deleted**: +a page that could be checked and then cannot is a page that got weaker as it got more accurate. + +All five were rewritten on **2026-08-09** (work item 181). None of them had been wrong; all of them +were partial, describing the product by one signal's pipeline where a reader was deciding what it is. + +| | what it said before | what it says now | +|---|---|---| +| `pyproject.toml` | one signal's two endpoints, in the line PyPI shows | the one-sentence claim above | +| `README.md` | opened with the pipeline as *what it does* | opens with what is verified; the pipeline is named as the error signal's path | +| the roadmap | a segment and an obstacle order, with no product above them | says what is being roadmapped, and that the sections are not three products in a queue | +| the interface document | readers and their three questions | says what the surface is a surface *of*, and the constraint that follows | +| `docs/status.md` | accurate about the halves | says what they are halves of, per signal, with the state of each | + +**The images caught up too**, on the same day and after the sentence above first said they would +not. `images/banner.svg` is the top of the README; `images/the-pipeline.svg` now says it is the path +*a production error* takes rather than what Hullwork does; and `images/social-preview.png` — the +card a link to this repository renders as, anywhere it is pasted — was the last one and the one with +the most reach. + +That PNG had **no source in the repository**, which is what made it look unfixable. It has one now: +`images/social-preview.svg`, rasterised by `scripts/render-social-preview.sh`, both committed. + +**What is guarded rather than remembered.** +`test_no_published_document_describes_the_product_by_its_plumbing` asserts that the old sentence +appears in no published document, in no packaging metadata, and in **no image source**. It failed +the day it was written, which is what made it a gate rather than a decoration, and it caught three +more instances afterwards — including the first line of the README, which is the banner's alt text +and the first thing a screen reader announces. + +## What is released, and what is only built + +Stated here because everything above describes a mechanism with three oracles, and a reader who +takes that as an inventory would be misled by this page rather than by the ones it corrects. + +| signal | state, 2026-08-09 | +|---|---| +| a production error | **released** | +| a dependency advisory | **built and unreleased** — work items 172–180, absent from `published-surface.json`, which records `0.1.0a7` | +| a static finding | does not exist | + +`CONTRIBUTING.md`'s rule is that documentation describes the released artefact rather than the +working tree, so no document may show that command until a release carries it — which is why it is +not named on this page either. The guard refused the first draft of this section for exactly that, +and naming a command a reader cannot run would invite them to type it and be told it does not +exist. diff --git a/hullwork.yml b/hullwork.yml index 3f88d2e..f555613 100644 --- a/hullwork.yml +++ b/hullwork.yml @@ -1,8 +1,8 @@ # Hullwork, watched by Hullwork. # # The lanes below are matched against the *error* — its title and culprit — not against the parts -# of the repository a work item would touch. Those are two different questions, and the worker contract -# answers the other one: what an unattended worker may change. This file answers what an incoming +# of the repository a fix would touch. Those are two different questions: the derived path policy +# (`hullwork projects lanes`) answers the second one. This file answers what an incoming # production fault is allowed to become. # # Red wins over amber, amber over green, and anything matching nothing at all is red. @@ -36,7 +36,7 @@ autofix: - indexerror amber: # Anything about the shape of the database or what is installed: recoverable, but a fix is a - # migration or a dependency change, which the worker contract already keeps away from a worker. + # migration or a dependency change, which the derived path policy already keeps a human on. - alembic - migration - integrityerror diff --git a/hullwork/__init__.py b/hullwork/__init__.py index ebbff5c..ad13fcb 100644 --- a/hullwork/__init__.py +++ b/hullwork/__init__.py @@ -1,5 +1,5 @@ """Hullwork — from production errors to reviewable pull requests, on your own infrastructure.""" -__version__ = "0.1.0a7" +__version__ = "0.1.0a8" __all__ = ["__version__"] diff --git a/hullwork/bump.py b/hullwork/bump.py new file mode 100644 index 0000000..f27109a --- /dev/null +++ b/hullwork/bump.py @@ -0,0 +1,528 @@ +"""Does the upgrade survive the project's own suite. Item 173, DR-0016. + +**The question nobody else answers.** Renovate opens the pull request; when the bump breaks the +suite it leaves the body where it fell. Item 172 says which pins have a published advisory. This +says what happens if you take the fix — and the oracle is not a test an agent wrote for the +occasion, it is the suite the project already has, which is the one authority in this system that +no model can flatter. + +**No agent, no gateway, no model token.** There is nothing here for a model to do: rewriting +`jinja2==2.4.1` to `jinja2==2.10.1` is an edit, and the verdict comes from running commands. A +clean answer costs nothing but the two builds. + +**Why this needs no change to the sandbox's isolation**, which is the property that would have made +it a bad idea. `image.dependency_digest` makes the tag the content, so changing a version *is* a +rebuild with no invalidation to remember; and `sandbox/image.py` puts the network in the build and +never in the phase. The upgraded package is therefore installed while there is a network, and the +suite still runs against nothing. + +Shaped after `dispatch.dispatch()` deliberately: it is handed a box and a directory and gives back +a decision, so it is testable without Docker. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import Protocol + +from hullwork import dependencies, resolve, testoutput +from hullwork.sandbox.run import RunResult + +log = logging.getLogger(__name__) + +#: How long one suite run may take. The same ceiling the dispatcher's gates use, for the same +#: reason: a gate that runs out of time says nothing about the upgrade. +GATE_TIMEOUT_SECONDS = 1800 + + +class Verdict(StrEnum): + """What running the project's own suite against the upgrade said.""" + + #: The suite passed before and passes after. **Not** "this is safe" — see `says`. + CLEAN = "clean" + #: The finding. This upgrade breaks these tests. + BREAKS = "breaks" + #: The build failed, which is a different fact from the suite failing. + WILL_NOT_INSTALL = "will-not-install" + #: The suite was already failing, so no claim can be made either way. + ALREADY_RED = "already-red" + #: This file cannot be rewritten without producing something that will not install. + CANNOT_REWRITE = "cannot-rewrite" + #: The dependency could not be moved: the resolver failed, or the manifest forbids the version. + CANNOT_MOVE = "cannot-move" + + +@dataclass(frozen=True) +class Runs: + """The two suite runs a verdict rests on, in the runner's own words. Item 178. + + **Kept rather than recomputed**, because by the time anybody renders an artefact the containers + are gone and the tree has been restored. Everything a reviewer checks is here: what was run, + what it exited with twice, and the line the runner printed about itself. + + The summary lines are `testoutput.verdict_line`'s reading of captured output from an arbitrary + command, so anything that renders them scrubs them first — which is `evidence`'s rule and the + reason this carries the text rather than pre-formatting it. + """ + + command: str + before_exit: int + after_exit: int + before_summary: str = "" + after_summary: str = "" + + +@dataclass(frozen=True) +class Answer: + """A verdict about one upgrade, and the evidence for it.""" + + verdict: Verdict + package: str + was: str + to: str + detail: str = "" + #: The dependency files **as the passing run saw them**, kept only for a clean verdict. + #: + #: This is what item 178 opens a pull request with, and carrying it is not an optimisation. + #: `attempt` restores every file it moved, so working the diff out afterwards would mean running + #: the resolver a second time — and a lock regenerated twice can differ: a version published in + #: between, a different ordering, a registry that answered differently. Publishing files that + #: are not the ones the suite passed against is the defect item 045 is named after, and this is + #: the one place it could come back. + files: dict[str, bytes] = field(default_factory=dict) + #: What the two gates did, for the artefact. `None` for a verdict that never ran two. + runs: Runs | None = None + + @property + def says(self) -> str: + """The claim in DR-0016's own words, which are deliberately narrower than they could be. + + *The suite passed before this change and passes after it* — never *this is safe* and never + *this fixes the vulnerability*. A suite that never exercised the upgraded library says so + by staying green, and a reader who knows that reads this correctly. Widening it here would + be the defect item 171 removed: a claim that reads as more than it measured. + """ + if self.verdict is Verdict.CLEAN: + return ( + f"{self.package} {self.was} → {self.to}: your suite passed before this change and " + f"passes after it. That is what was measured — not that the upgrade is safe, and " + f"not that it fixes anything your suite does not exercise." + ) + if self.verdict is Verdict.BREAKS: + return f"{self.package} {self.was} → {self.to}: this upgrade breaks your suite." + if self.verdict is Verdict.WILL_NOT_INSTALL: + return f"{self.package} {self.was} → {self.to}: the environment could not be built." + if self.verdict is Verdict.ALREADY_RED: + return ( + "your suite does not pass on an untouched checkout, so nothing can be claimed " + "about any upgrade. Nothing was rewritten." + ) + if self.verdict is Verdict.CANNOT_MOVE: + return f"{self.package} {self.was} → {self.to}: could not be moved. {self.detail}" + return f"{self.package}: this file cannot be rewritten safely." + + +#: A `requirements.txt` pin, in five groups so the name can be compared on its own and the version +#: replaced without rebuilding the line: indent, **name**, extras-and-operator, version, tail. +#: +#: Extras, environment markers and comments survive because only the version group is replaced. A +#: line rebuilt from its parsed parts would quietly drop the marker, which changes what gets +#: installed on other platforms — a silent behaviour change from a cosmetic decision. +_PIN = re.compile(r"^(\s*)([A-Za-z0-9._-]+)((?:\s*\[[^\]]*\])?\s*==\s*)([^\s;#]+)(.*)$") + + +def _canonical(name: str) -> str: + """PEP 503 normalisation, because two spellings of one package are one package. + + OSV answers with the canonical name and a requirements file may carry any of its spellings — + `Jinja2`, `jinja_2`, `jinja.2` all name the same distribution. Comparing raw strings would + refuse to rewrite a pin that is plainly there. + """ + return re.sub(r"[-_.]+", "-", name).lower() + + +#: Files whose version strings cannot be edited in place, and the reason, in the words the refusal +#: uses. **Not a "not yet"**: these files carry per-artefact hashes, so a hand-edited version string +#: describes an artefact the hash does not match. The install then fails on the checksum, or — worse +#: — succeeds against a stale cache and the suite runs against a version nobody chose. +#: Dependency files that are a **list**, where the line is the pin and rewriting it is the whole +#: edit. Everything not here is a resolved graph and needs its ecosystem's resolver. +#: +#: Stated as an allow-list rather than as a list of refusals, and a test is why: the refusals used +#: to name three lock files, so the day a reader learns `Cargo.lock` — which has no resolver — it +#: would have been edited by hand with nothing objecting. The safe answer has to be the default. +#: +#: **A predicate rather than a set of names since item 180**, and the reason is that widening the +#: reader without widening this produced a refusal that was *false*: `requirements/prod.txt` was +#: declined as *"a resolved graph rather than a list of versions"*, which is the opposite of what it +#: is. Every upgrade in every layout other than a root `requirements.txt` would have been refused, +#: with a wrong reason each time. `dependencies.is_requirements` is the one place that decides, +#: so a layout that becomes readable becomes editable in the same edit. +def editable_by_hand(source: str) -> bool: + """Whether this file is a list of versions, where rewriting the line is the whole edit.""" + return dependencies.is_requirements(source) + +#: Per-file reasons, for the ones where the generic sentence is not the useful one. +CANNOT_BE_EDITED: dict[str, str] = { + "package-lock.json": "it carries `integrity` hashes per package", + "uv.lock": "it carries a `sha256` per artefact", + "poetry.lock": "it carries a `sha256` per artefact", +} + + +class CannotRewriteError(Exception): + """The dependency file cannot be edited into a valid one. Names the file and the reason.""" + + +def rewrite_pin(text: str, package: str, to: str) -> str: + """`package==old` becomes `package==to`, and everything else on the line survives. + + Extras, environment markers and trailing comments are preserved because only the version group + is replaced — `httpx[http2]==0.27.0 ; python_version >= "3.8" # pinned` keeps all three. + + A `--hash=` line is refused for the same reason a lock file is: the hash describes the + artefact that was pinned, and a version changed out from under it will not install. + """ + wanted = _canonical(package) + out: list[str] = [] + seen = False + for line in text.splitlines(keepends=True): + matched = _PIN.match(line.rstrip("\n")) + if matched and _canonical(matched.group(2)) == wanted: + if "--hash=" in line: + msg = ( + f"{package} is pinned with `--hash=`, and a hash describes the artefact that " + f"was pinned. Changing the version without recomputing it produces a " + f"requirements file that will not install." + ) + raise CannotRewriteError(msg) + ending = "\n" if line.endswith("\n") else "" + indent, name, operator, _, tail = matched.groups() + out.append(f"{indent}{name}{operator}{to}{tail}{ending}") + seen = True + else: + out.append(line) + if not seen: + msg = f"no `{package}==…` line to rewrite" + raise CannotRewriteError(msg) + return "".join(out) + + +def can_rewrite(source: str) -> None: + """Raise `CannotRewrite` when this file's versions must not be edited by hand. + + Called before anything runs, so a project whose only pins are in a lock file is told at once + rather than after paying for a baseline build. + """ + name = source.rsplit("/", 1)[-1] + # A list of versions: the line *is* the pin, so rewriting it is the whole edit. + if editable_by_hand(source): + return + # Anything else is a resolved graph, and only its own tool can move one (item 175). + if resolve.resolver_for(source) is not None: + return + why = CANNOT_BE_EDITED.get(name, "it is a resolved graph rather than a list of versions") + msg = ( + f"{source} cannot be rewritten here: {why}, so editing a version string by hand leaves a " + f"file whose hashes describe the version that was there before. Upgrading it properly " + f"means running that ecosystem's own tool, which this does not do yet — so it refuses " + f"rather than producing a lock file that cannot install." + ) + raise CannotRewriteError(msg) + + +class Box(Protocol): + """What this needs from a sandbox, and nothing else. + + **Structural**, so the tests need no Docker and so this module never imports one — the same + reason `dispatch` takes its box as an argument rather than building one. + """ + + worktree: Path + + def run(self, command: str, timeout: int = 0) -> RunResult: # pragma: no cover - protocol + ... + + +def attempt( + make_box: Callable[[], Box], + *, + tests: str, + source: str, + package: str, + was: str, + to: str, + rebuild: Callable[[str], str | None], + mover: Callable[[Path], str | None] | None = None, + touches: Sequence[str] | None = None, +) -> Answer: + """Baseline, rewrite, rebuild, run again. The three phases of DR-0016. + + `rebuild` takes the rewritten file's text and returns `None` on success or the reason on + failure. A parameter rather than an import because building an image is the caller's business: + this function never learns that Docker exists, which is what makes it testable without it. + + **`make_box` is called twice, and that is the whole correctness of this function.** The second + run has to happen in a box built from the *rebuilt* image; reusing the first one runs the + upgraded project's suite against the environment it had before the upgrade, and reports `clean` + for a version that was never installed. Taking a box rather than a factory is exactly that + defect, and it survived nineteen unit tests before a real Docker run found it — a double has no + image, so nothing in it could have noticed. + """ + # Before the baseline is paid for: a file nothing can move is a fact on disk. + if mover is None: + can_rewrite(source) + move = mover or editing(source, package, to) + guarded = tuple(touches or (source,)) + + # --- phase 0: is there a claim to make at all ------------------------------------------ + box = make_box() + baseline = box.run(tests, GATE_TIMEOUT_SECONDS) + if not baseline.ok: + # Before anything is rewritten and before a second build is paid for. A suite that is + # already red cannot support "passed before and passes after", and blaming the upgrade for + # it would be the same error `dispatch` made until item 043. + return Answer( + Verdict.ALREADY_RED, package, was, to, + detail=_tail(baseline.output), + ) + + # --- phase 1: move the dependency, however this file is moved ------------------------- + # + # **Every file the move can touch is snapshotted, not just the one named.** Found by item 175's + # gate: `npm install` rewrites `package.json` as well as the lock, and item 174 had already + # shown what happens when a candidate leaves anything behind — the next candidate's baseline + # describes the previous one, silently. Restoring only the lock would be that defect one file + # over, invisible in exactly the same way. + before = { + name: (box.worktree / name).read_bytes() + for name in guarded + if (box.worktree / name).exists() + } + + try: + failed = move(box.worktree) + if failed is not None: + return Answer(Verdict.CANNOT_MOVE, package, was, to, detail=failed) + + # --- phase 2: rebuild — where the network is — and run the suite again ------------- + failure = rebuild((box.worktree / source).read_text(encoding="utf-8")) + if failure is not None: + return Answer(Verdict.WILL_NOT_INSTALL, package, was, to, detail=str(failure)) + + # **A new box, on the image the rebuild just produced.** See the docstring: this line is + # the difference between measuring the upgrade and measuring what it replaced. + after = make_box().run(tests, GATE_TIMEOUT_SECONDS) + # **Read inside the `try`, because the `finally` below puts the old versions back.** These + # bytes are the whole of what item 178 opens a pull request with, and they only exist + # between these two lines: after this function returns, the tree describes the version the + # project had before, which is the opposite of what a reviewer would be asked to merge. + moved = { + name: (box.worktree / name).read_bytes() + for name in guarded + if after.ok and (box.worktree / name).exists() + } + finally: + for name, content in before.items(): + (box.worktree / name).write_bytes(content) + + runs = Runs( + command=tests, + before_exit=baseline.exit_code, + after_exit=after.exit_code, + # `None` when the runner printed nothing this reader recognises, and an empty string is + # the honest rendering of that: the exit codes beside it are the claim either way. + before_summary=testoutput.verdict_line(baseline.output) or "", + after_summary=testoutput.verdict_line(after.output) or "", + ) + if after.ok: + return Answer(Verdict.CLEAN, package, was, to, files=moved, runs=runs) + return Answer(Verdict.BREAKS, package, was, to, detail=_tail(after.output), runs=runs) + + +def _tail(output: str, limit: int = 12) -> str: + """The lines a runner uses to say what failed, or the tail when none are recognised. + + Deliberately the same shape as `dispatch.failing_lines` and deliberately not an import of it: + that one is about an agent's attempt and carries its vocabulary. A tail is always better than + the empty string, which is what a stricter matcher produces on an unknown runner. + """ + marked = [ + line for line in output.splitlines() + if line.startswith(("FAILED", "FAIL", "ERROR", "not ok", " ✗", "✗")) + ] + chosen = marked[:limit] if marked else output.splitlines()[-limit:] + more = len(marked) - limit if len(marked) > limit else 0 + text = "\n".join(chosen) + return f"{text}\n… and {more} more" if more > 0 else text + + +def candidates(advisories: object) -> list[str]: + """The fixed versions to try, in the order OSV gave them, without repeats. + + **This is item 172's deferred question, answered the way DR-0016 said it would be.** That item + prints every published fixed version and chooses none, because choosing would mean comparing + versions under two ecosystems' ordering rules. Here there is no need to compare: each is tried + and the suite decides. Hullwork needs no ordering rule per ecosystem because it can execute. + """ + seen: list[str] = [] + for advisory in advisories: # type: ignore[attr-defined] + for version in advisory.fixed: + if version not in seen: + seen.append(version) + return seen + + +@dataclass(frozen=True) +class Report: + """What trying every candidate for one package concluded.""" + + package: str + was: str + answers: tuple[Answer, ...] + + @property + def settled(self) -> Answer | None: + """The first candidate whose suite stayed green, or `None` if none did.""" + return next((a for a in self.answers if a.verdict is Verdict.CLEAN), None) + + +def verify( + *, + tests: str, + source: str, + package: str, + was: str, + versions: Sequence[str], + make_box: Callable[[str], Box], + rebuild: Callable[[str], str | None], + mover: Callable[[Path], str | None] | None = None, + touches: Sequence[str] | None = None, + pending: dict[str, str] | None = None, +) -> Report: + """Try each candidate until one leaves the suite green. Item 174. + + **Stops at the first clean answer** rather than trying them all: the remaining candidates are + higher versions of the same fix, and a project that upgrades further than it has to is a + project taking a larger change than the advisory asked for. + + `make_box` and `rebuild` are parameters for the reason `attempt`'s box is: building images and + starting containers is the caller's business, so this stays testable without Docker. + """ + answers: list[Answer] = [] + for version in versions: + # The mover is built once by the caller and asks for whichever candidate is current, so + # this is where the two are kept in step. + if pending is not None: + pending["version"] = version + answer = attempt( + lambda version=version: make_box(version), # type: ignore[misc] + tests=tests, source=source, + package=package, was=was, to=version, rebuild=rebuild, + mover=mover, touches=touches, + ) + answers.append(answer) + # A red baseline is about the project, not the candidate: trying the next version would ask + # the same broken suite the same question and get the same answer. + if answer.verdict in (Verdict.CLEAN, Verdict.ALREADY_RED): + break + return Report(package, was, tuple(answers)) + + +def editing(source: str, package: str, to: str) -> Callable[[Path], str | None]: + """The mover for a file that is a list of versions: rewrite the line. + + Only `requirements.txt` reaches here — `can_rewrite` has already refused any lock file with no + resolver, and the caller supplies a resolver-backed mover for the ones that have one. + + **Public since item 179**, which needs the same move outside a verdict: a refit has to put the + upgrade into the tree the agent will work in, and a second implementation of *rewrite the pin* + is a second thing that can come to disagree about what a requirements line means. + """ + + def move(worktree: Path) -> str | None: + path = worktree / source + try: + path.write_text(rewrite_pin(path.read_text(encoding="utf-8"), package, to), + encoding="utf-8") + except CannotRewriteError as refused: + return str(refused) + return None + + return move + + +class Needs(StrEnum): + """What a verdict asks of a person, which is the only useful way to order them. + + **Not severity.** OSV publishes one and this does not read it yet, so ordering by it would be + ordering by something unmeasured — the habit DR-0017 exists to break. This orders by what was + actually established: whether a person has to do anything, and how much. + """ + + #: Your suite is red. Nothing else here can be decided until it is not. + FIX_YOUR_SUITE = "fix your suite first" + #: A fix exists, it breaks named tests, and somebody has to make the code fit. + NEEDS_WORK = "needs work" + #: Something is in the way of even trying — no fix published, a manifest that forbids it. + BLOCKED = "blocked" + #: Verified green. The only thing left is to take it. + JUST_TAKE_IT = "ready to take" + + +#: Worst first: what stops everything, then what needs a person, then what is merely stuck, then +#: what needs nothing. A reader who stops after the first section has read the part that mattered. +_ORDER = (Needs.FIX_YOUR_SUITE, Needs.NEEDS_WORK, Needs.BLOCKED, Needs.JUST_TAKE_IT) + + +def needs_of(report: Report) -> Needs: + """What this package asks of a person.""" + verdicts = [a.verdict for a in report.answers] + if Verdict.ALREADY_RED in verdicts: + return Needs.FIX_YOUR_SUITE + if report.settled is not None: + return Needs.JUST_TAKE_IT + if Verdict.BREAKS in verdicts: + return Needs.NEEDS_WORK + return Needs.BLOCKED + + +def broke(report: Report) -> int: + """How many tests the best candidate broke, or 0. + + Used to order within `NEEDS_WORK`, smallest first: the upgrade that breaks two tests is the one + a person can close this afternoon, and putting the twelve-test one above it buries the + achievable under the daunting. + """ + counts = [ + len([line for line in a.detail.splitlines() if line.strip()]) + for a in report.answers + if a.verdict is Verdict.BREAKS + ] + return min(counts) if counts else 0 + + +def ranked(reports: Sequence[Report]) -> list[Report]: + """The queue in the order a person should work it. DR-0018 step 2. + + **This is the answer to the complaint that Renovate cannot answer.** *"Here is every update, + you decide"* is noise because nothing in it is ranked — and ranking requires knowing what each + one does, which requires running them. That is the axis they cannot move. + """ + return sorted( + reports, + key=lambda r: (_ORDER.index(needs_of(r)), broke(r), r.package), + ) + + +def summary(reports: Sequence[Report]) -> dict[Needs, int]: + """How many fall in each bucket. The sentence that replaces forty undecided pull requests.""" + counted = dict.fromkeys(_ORDER, 0) + for report in reports: + counted[needs_of(report)] += 1 + return counted diff --git a/hullwork/cli.py b/hullwork/cli.py index 1008bac..ba0351a 100644 --- a/hullwork/cli.py +++ b/hullwork/cli.py @@ -14,12 +14,13 @@ import json import logging import os +import shutil import signal import subprocess import sys import threading from collections.abc import Callable, Sequence -from contextlib import suppress +from contextlib import ExitStack, suppress from dataclasses import dataclass from datetime import UTC, datetime, timedelta from pathlib import Path @@ -31,22 +32,30 @@ from hullwork import ( __version__, + bump, credentials, db, + dependencies, doctor, + features, lease, operator, + osv, outcomes, page, propose, readiness, recurrence, + refit, + resolve, spend, territory, triage, + upgrades, work, ) from hullwork import decisions as decide +from hullwork import dispatch as dispatch_module from hullwork import upstream as upstream_module from hullwork.config import ConfigError, Settings, get_settings from hullwork.credentials import PushCapability @@ -348,6 +357,741 @@ def _the_image_must_be_able_to_host_a_phase( raise CommandError(refusal) +def _cmd_deps(args: argparse.Namespace, settings: Settings, out: TextIO) -> int: + """Which pinned dependencies have something published against them. Item 172, DR-0016. + + **Standalone, and that is the product claim rather than a convenience.** No forge, no model, no + Docker, no database — a person can run this against their own checkout in the first minute, + before they have decided anything. It is the only half of DR-0016 that needs nothing. + """ + checkout = Path(args.checkout).resolve() + # `--fix` and `--open` are `--verify` plus one more step each: there is nothing to fix that has + # not first been measured breaking, and nothing to open that has not first been measured + # passing. Read once, here, so everything below asks the same question. + opening = bool(getattr(args, "open", False)) + verifying = bool(args.verify or getattr(args, "fix", False) or opening) + # **Before the lock files are read and before OSV is asked**, for `_manifest_for_verify`'s + # reason and with more at stake: `--open` is the one flag here that can write to somebody's + # repository, and finding out after two container builds that there is no credential is a + # refusal that arrives after the work it invalidates. + code_forge = _forge_for_opening(settings, checkout) if opening else None + if getattr(args, "fix", False): + # **Item 048's finding, on the path that had not learned it** (found by running `--fix` for + # the first time, 2026-08-09). This was raised inside `refit.run`, so it arrived *after* + # every container had been built and every suite run — the most expensive place available — + # and it arrived as a `WiringError` traceback rather than as a refusal, which item 120 is + # about. The message itself was right; where and how it appeared was not. + _refuse_without_a_model(settings) + # **Before the lock files are read and before OSV is asked.** Found by running it: validating + # the manifest after the report meant paying for a network round trip and a full listing to be + # told a file was missing — and the refusal arrived interleaved with the output it invalidated. + manifest = _manifest_for_verify(checkout) if verifying else None + paths = _tracked_files(checkout) + + def read(path: str) -> str | None: + try: + return (checkout / path).read_text(encoding="utf-8", errors="replace") + except OSError: + return None + + pinned = dependencies.read_lockfiles(paths, read) + if not pinned: + raise CommandError( + f"no lock file in {checkout}: looked for " + f"{', '.join(dependencies.WHAT_IS_LOOKED_FOR)}.\n" + f" A declaration is a range and a range does not say what your build resolved to, so " + f"there is nothing here a vulnerability database can be asked about. Commit a lock " + f"file, or pin with `==` in requirements.txt." + ) + + sources = sorted({d.source for d in pinned}) + print(f"{len(pinned)} pinned dependencies, from {', '.join(sources)}", file=out) + + # Said before the answer rather than after it: a file whose ranges were skipped reports fewer + # dependencies than it has, and a reader who learns that afterwards has already believed it. + # + # **Every requirements file that was read, not the root one by name** (item 180). Keyed off the + # same predicate the reader uses, so a layout that becomes readable becomes countable in the + # same edit — two places deciding what a requirements file is would eventually disagree, and + # the half that goes quiet is this one. + for source in sources: + if dependencies.is_requirements(source): + text = read(source) or "" + skipped = dependencies.unpinned(text) + if skipped: + print( + f" {skipped} line(s) in {source} are ranges rather than `==` pins and were " + f"not checked", + file=out, + ) + + # **Quiet here and nowhere else.** httpx2 logs every request at INFO, which is right for the + # dispatcher — those lines are how an attempt gets diagnosed — and wrong for a report a person + # reads. This is the command a stranger runs first, and one `HTTP Request: POST …` line in the + # middle of its output is the kind of friction the cold evaluations kept finding. + logging.getLogger("httpx2").setLevel(logging.WARNING) + with osv.Osv() as database: + findings = database.affected(pinned) + + if not findings: + print("\nNothing published against any of those versions.", file=out) + return 0 + + print(f"\n{len(findings)} with a published advisory:\n", file=out) + for finding in findings: + dep = finding.dependency + print(f" {dep.name} {dep.version} ({dep.ecosystem}, {dep.source})", file=out) + for advisory in finding.advisories: + if advisory.has_a_fix: + where = " or ".join(advisory.fixed) + ends = f"fixed in {where}" + else: + # Not "no fix found": the advisory publishes none, which is a fact about the + # advisory rather than a gap in this reading. + ends = "no fixed version is published — there is no upgrade to attempt" + print(f" {advisory.id}: {ends}", file=out) + if advisory.summary: + print(f" {advisory.summary}", file=out) + print(f" {advisory.url}", file=out) + print("", file=out) + + if not verifying: + print( + "Whether any of these upgrades survives your own test suite is a different question, " + "and nothing above has run it. `--verify` runs it.", + file=out, + ) + return 0 + + assert manifest is not None # noqa: S101 - built above when --verify is set + reports = _verify_upgrades(checkout, paths, read, manifest, findings, out) + if opening: + assert code_forge is not None # noqa: S101 - built above when --open is set + _open_the_ones_that_pass(code_forge, checkout, manifest, findings, reports, out) + if not getattr(args, "fix", False): + return 0 + return _fix_the_ones_that_break( + args, settings, checkout, paths, manifest, findings, reports, out + ) + + +def _manifest_for_verify(checkout: Path) -> Manifest: + """The manifest `--verify` needs, or a refusal naming exactly what is missing. Item 174. + + **Called before the lock files are read and before OSV is asked**, because everything it checks + is knowable from disk. Validating afterwards spent a network round trip and a full listing to + tell somebody a file was missing, and printed the refusal interleaved with the report it had + just invalidated. Found by running it. + """ + manifest_path = checkout / MANIFEST_FILENAME + if not manifest_path.exists(): + raise CommandError( + f"--verify needs a {MANIFEST_FILENAME} in {checkout}: it says which image your tests " + f"run in and what the test command is, and neither can be guessed.\n" + f" `hullwork propose --checkout {checkout}` writes one from your CI configuration." + ) + manifest = parse_manifest(manifest_path.read_text(encoding="utf-8")) + if manifest.runtime is None or not manifest.runtime.base: + raise CommandError( + "--verify needs `runtime.base`: an image your tests already run in. Without it there " + "is nothing to build the upgrade into." + ) + if not manifest.tests: + raise CommandError( + "--verify needs `tests`: the command that runs your suite. That suite is the whole " + "verdict — without it there is nothing to ask." + ) + return manifest + + +def _verify_upgrades( + checkout: Path, + paths: Sequence[str], + read: Callable[[str], str | None], + manifest: Manifest, + findings: Sequence[osv.Finding], + out: TextIO, +) -> list[bump.Report]: + """Apply each published fix in a sandbox and let the project's own suite decide. Item 174. + + Returns the reports as well as printing them, because item 179 has to act on the same ones the + reader was shown — recomputing them would be a second verdict about the same upgrade, and the + two could differ by the time anybody noticed. + """ + print("\n--- verifying each published fix against your own suite ---\n", file=out) + reports: list[bump.Report] = [] + for finding in findings: + dep = finding.dependency + versions = bump.candidates(finding.advisories) + refusal = _cannot_be_verified(dep, manifest, versions) + if refusal is not None: + print(f" {dep.name}: {refusal}\n", file=out) + # **Counted, not merely printed** (item 182). A refusal that only goes to the terminal + # is absent from the summary below, so a run that could verify none of six reads as + # `0 blocked` — and the one number in this whole command that a person acts on is a + # count. "I could not verify this" is a first-class answer and it has to be in the + # tally, which is the property `docs/what-hullwork-is.md` puts second. + reports.append( + bump.Report( + package=dep.name, + was=dep.version, + answers=( + bump.Answer( + bump.Verdict.CANNOT_MOVE, dep.name, dep.version, + versions[0] if versions else "", detail=refusal, + ), + ), + ) + ) + continue + report = _verify_one(checkout, paths, read, manifest, dep, versions, out) + if report is not None: + reports.append(report) + + _print_the_queue(reports, out) + return reports + + +def _forge_for_opening(settings: Settings, checkout: Path) -> object: + """The credential `--open` pushes through, or a refusal that says what is missing. Item 178. + + **It is `HULLWORK_FORGE_CODE_TOKEN`, and the operator decided that on 2026-08-09.** The + alternative was a third token of its own, and it was declined for a reason worth keeping: a + third token would need exactly the same scope — `write:repository` — so it would be an + audit boundary rather than a capability boundary, which is not what the split between + `forge_token` and `forge_code_token` is. That one is real and was measured (item 073); a + same-scope sibling would be the same power under another name. + + What that decision costs is one sentence, and it has been paid: `config.py` no longer calls this + *the credential an agent pushes through*, because this path opens pull requests with no agent + having run. Nobody may infer "a model was called" from the fact that something was pushed. + + Called before the lock files are read and before OSV is asked, so a missing credential costs + nothing to discover. + """ + forge = make_code_forge(settings) + if forge is None: + raise CommandError( + "--open needs HULLWORK_FORGE_URL and HULLWORK_FORGE_CODE_TOKEN: it opens pull " + "requests, which is the one thing in `deps` that writes to your repository.\n" + " Everything else here needs no account anywhere — `--verify` runs your suite " + "against each upgrade and prints the answer, and it is the honest way to see what " + "this would open before letting it." + ) + coordinate = _coordinate_from(_origin_url(checkout)) + if coordinate == "owner/name": + forge.close() + raise CommandError( + f"--open needs to know which repository this is, and {checkout} has no usable `origin` " + f"remote to read it from.\n" + f" Add one, or run without --open: the verification does not need a coordinate " + f"because it opens nothing." + ) + return forge + + +def _refuse_without_a_model(settings: Settings) -> None: + """Refuse `--fix` before anything is built when no model credential is configured. Item 048. + + Knowable from the settings and nothing else, so it costs nothing to answer — which is the only + reason a refusal belongs this early. `--verify` is untouched: it calls no model and must keep + needing no credential of any kind, which is the property `deps` is sold on. + """ + try: + work._model_credential(settings) + except work.WiringError as exc: + raise CommandError( + f"{exc}\n" + f" `--verify` on its own needs no credential at all and still runs your suite " + f"against every published fix — it is `--fix`, which asks an agent to change your " + f"code, that needs a model." + ) from exc + + # **The other thing every agent run needs, and it was found the expensive way** (item 191). + # `deps --fix` died at the gateway after OSV, four image builds and two suite runs, on the + # first real model call this command ever made. Asked here, where the credential is asked. + from hullwork.sandbox.net import why_the_gateway_cannot_start + + missing = why_the_gateway_cannot_start() + if missing: + raise CommandError(missing) + + +def _open_the_ones_that_pass( + code_forge: object, + checkout: Path, + manifest: Manifest, + findings: Sequence[osv.Finding], + reports: Sequence[bump.Report], + out: TextIO, +) -> None: + """Open one draft pull request per verified-green upgrade. Item 178, DR-0018 step 3. + + The end of DR-0018's sentence, and the first thing in this line of work that needs a credential + able to write: *we open the thirty-one that pass and tell you what to do with the nine that do + not*. The nine are already on screen by the time this runs. + """ + from hullwork import trial + + eligible = upgrades.eligible(reports) + if eligible and not manifest.autofix.open_upgrades: + # **Said before the count, and as a decision** (DR-0019, item 187). This is the first thing + # a project can refuse while Hullwork is perfectly able to do it, so it must not read as a + # part that is missing — the verification above ran and its answer stands. + print( + f"\n{len(eligible)} upgrade(s) passed your suite and **none was opened**: this " + f"project has not permitted it.\n" + f" Set `autofix: {{open_upgrades: true}}` in {MANIFEST_FILENAME} if you want them " + f"opened. It is false by default because having the credential is not the same as " + f"having agreed, and the report above is what there is to act on either way.", + file=out, + ) + return + if not eligible: + print( + "\nNothing was verified green, so there is nothing to open. That is a result rather " + "than a failure: the report above is what there is to act on.", + file=out, + ) + return + + # **The commit the gates ran against**, read from the checkout that was verified rather than + # from the forge's idea of its own default branch. The base can move while a verification runs, + # and a branch rooted at wherever it points now contains a tree nobody tested. + base = trial.head_sha(checkout) + if base == "working tree": + print( + "\nThis checkout is not a git repository, so there is no commit to root a pull " + "request at and nothing was opened. What was verified is above.", + file=out, + ) + return + + by_package = {f.dependency.name: f.advisories for f in findings} + coordinate = _coordinate_from(_origin_url(checkout)) + print(f"\n--- opening {len(eligible)} verified-green upgrade(s) on {coordinate} ---\n", + file=out) + opened = upgrades.open_them( + code_forge, repo=coordinate, reports=eligible, + advisories=by_package, base_sha=base, + permitted=manifest.autofix.open_upgrades, + ) + for where in opened: + print(f" {where}", file=out) + if len(opened) < len(eligible): + # Never silence: a package that produced no pull request is either already open from a + # previous run or something the forge refused, and both are facts a reader needs. + print( + f"\n {len(eligible) - len(opened)} opened nothing — already open from an earlier " + f"run, or refused by the forge. The log says which.", + file=out, + ) + print( + f"\nAll drafts, rooted at {base[:12]}, one per package. Nobody merges them but you.", + file=out, + ) + + +def _fix_the_ones_that_break( + args: argparse.Namespace, + settings: Settings, + checkout: Path, + paths: Sequence[str], + manifest: Manifest, + findings: Sequence[osv.Finding], + reports: Sequence[bump.Report], + out: TextIO, +) -> int: + """Hand each broken upgrade to an agent and let the gates decide. Item 179, DR-0018 step 4. + + **The middle of the queue, which is the part nobody else ships.** The verified-green ones need + no agent and are item 178's to deliver; the blocked ones have nothing to try. What is left is + *six break, tests named*, and until this existed the honest answer to those was a list. + + Worst-first through `bump.ranked`, so a run that is interrupted has spent its money on the ones + a person would have started with. + """ + into = Path(args.into).resolve() + queue: list[tuple[bump.Report, refit.Upgrade]] = [] + for report in bump.ranked(reports): + if bump.needs_of(report) is not bump.Needs.NEEDS_WORK: + continue + finding = next( + ( + f for f in findings + if f.dependency.name == report.package and f.dependency.version == report.was + ), + None, + ) + if finding is None: # pragma: no cover - every report was built from one + continue + first = finding.advisories[0] if finding.advisories else None + upgrade = refit.from_report( + report, + source=finding.dependency.source, + guarded=refit.guarded_for(finding.dependency.source), + advisory=first.id if first else "", + url=first.url if first else "", + ) + if upgrade is not None: + queue.append((report, upgrade)) + + if not queue: + print( + "\nNothing broke that an agent could be asked about, so --fix had nothing to do.", + file=out, + ) + return 0 + + print(f"\n--- asking an agent to make {len(queue)} upgrade(s) fit ---\n", file=out) + failures = 0 + for _report, upgrade in queue: + print(f" {upgrade.package} {upgrade.was} → {upgrade.to}", file=out) + try: + outcome = refit.run( + settings, checkout, manifest, upgrade, + present=paths, into=into, repo=_coordinate_from(_origin_url(checkout)), + ) + except refit.NotUpgradableError as refused: + # The upgrade never went into the tree, so no attempt was begun and nothing was spent. + # Said as the fact about the project that it is, in the resolver's own words. + print(f" could not be applied: {refused}\n", file=out) + failures += 1 + continue + except work.WiringError as broken: + # Belt and braces over the refusal above: an engine this build does not know is a + # per-project fact, and a queue of six must not end at the first one that has it. + print(f" not attempted: {broken}\n", file=out) + failures += 1 + continue + print(f" {outcome.outcome.value}: {outcome.detail.splitlines()[0]}", file=out) + if outcome.pull_request: + print(f" written to {outcome.pull_request}\n", file=out) + else: + print("", file=out) + if outcome.outcome not in (AttemptOutcome.PR_OPEN, AttemptOutcome.PR_OPEN_LINT_FAILED): + failures += 1 + + print( + f"{len(queue) - failures} of {len(queue)} now pass your suite with the upgrade still " + f"applied. Nothing was opened anywhere: read what is in {into} and decide.", + file=out, + ) + return 0 + + +def _cannot_be_verified( + dep: dependencies.Dependency, manifest: Manifest, versions: Sequence[str] +) -> str | None: + """Why this upgrade cannot be measured at all, or `None` when it can. Item 182. + + Every reason here is knowable from the manifest and the finding, so all of them are answered + **before a container is built** — the same rule `can_rewrite` follows and for the same reason. + + **The one that was missing is the one a real repository found immediately.** `encode/flask` pins + four of its five advisory-carrying packages in `examples/celery/requirements.txt`, which is not + a file its image installs from. The image is built from `runtime.dependencies`; rewriting + anything outside that set changes no byte the build reads, so `dependency_digest` does not move, + the image is reused, and the suite passes exactly as it passed before. + + **Measured on 2026-08-09 against a real daemon**, on a tree with `requirements.txt` declared and + `extras/requirements.txt` not: + + [ready to take] jinja2 2.4.1 → 2.10.1 + $ docker run --rm python -c "import jinja2" + ModuleNotFoundError: No module named 'jinja2' + + *Ready to take*, for a package **not installed in the environment its suite ran in** — and with + item 178's `--open`, a pull request. That is item 174's defect arriving by a second route, and + it is the exact artefact DR-0017 says this product exists to prevent. + + An empty `runtime.dependencies` is not this case: `_verify_one` falls back to the file that + pins, so the build does read it. + """ + runtime = manifest.runtime + assert runtime is not None # noqa: S101 - `_manifest_for_verify` refused a manifest without one + + if runtime.install == "none": + # **The worse half of the same finding, and it is the default value.** With `install: none` + # the generated Dockerfile copies no dependency file and runs no installer + # (`sandbox/image.py`: `if runtime.install != "none" and runtime.dependencies`), so whatever + # the project's environment holds came from `runtime.base` and cannot be moved by editing a + # lockfile. DR-0007 makes *the project brings its own image* the primary path, so this is + # not an edge case — it is most projects. + # + # **Measured on 2026-08-09** against a base image carrying `jinja2 3.0.0`, on a checkout + # pinning `jinja2==2.4.1`: + # + # [ready to take] jinja2 2.4.1 → 2.10.1 + # $ docker run --rm python -c "import jinja2; print(...)" + # 3.0.0 + # 3.0.0 + # + # Neither version in the claim was ever installed. The "before" run did not use 2.4.1 and + # the "after" run did not use 2.10.1; both used a third version, and the verdict said the + # suite passed before the change and after it — which was true, and about nothing. + return ( + f"your manifest sets `install: none`, so the image is `{runtime.base}` exactly as it " + f"comes and nothing is installed from {dep.source}. Changing a version there cannot " + f"change what your suite runs against, so no verdict here would be about this " + f"upgrade.\n" + f" This is the primary path in DR-0007 and it is not a defect in your project: an " + f"image that already carries your dependencies is upgraded by rebuilding it, not by " + f"editing a pin. Declare an installer and the file it reads if you want this measured." + ) + + if runtime.dependencies and dep.source not in runtime.dependencies: + declared = ", ".join(runtime.dependencies) + return ( + f"{dep.source} is not one of the files your image is built from ({declared}), so " + f"changing a version in it changes nothing the suite would run against. Whatever this " + f"upgrade does, your suite cannot say — and a green run here would mean only that the " + f"file nobody installs from was edited.\n" + f" Declare it in `runtime.dependencies` if your build should read it, or upgrade it " + f"by hand: this is a fact about what your image installs, not about the upgrade." + ) + + if not versions: + # Not "no fix found": the advisory publishes none, which is a fact about the advisory rather + # than a gap in this reading. + return "no published fixed version, so there is nothing to try" + + try: + bump.can_rewrite(dep.source) + except bump.CannotRewriteError as refused: + return str(refused) + return None + + +def _print_the_queue(reports: Sequence[bump.Report], out: TextIO) -> None: + """The ranked report. DR-0018 step 2, and the whole of what it is for. + + **This is the part Renovate cannot produce.** Its documented weakness is that it hands over + every update undecided — noise rather than signal — and ranking them requires knowing what each + one does, which requires running them. Everything above ran them; this is where that is spent. + """ + if not reports: + return + counted = bump.summary(reports) + print("\n=== what to do with them ===\n", file=out) + for needs in ( + bump.Needs.FIX_YOUR_SUITE, bump.Needs.NEEDS_WORK, + bump.Needs.BLOCKED, bump.Needs.JUST_TAKE_IT, + ): + # Every bucket, including the empty ones: a reader has to be able to tell "none of these" + # from "this was not counted", and only one of those is good news. + print(f" {counted[needs]:>3} {needs.value}", file=out) + + print("", file=out) + for report in bump.ranked(reports): + needs = bump.needs_of(report) + settled = report.settled + where = f" → {settled.to}" if settled is not None else "" + broke = bump.broke(report) + cost = f", {broke} test(s) to fix" if broke and settled is None else "" + print(f" [{needs.value}] {report.package} {report.was}{where}{cost}", file=out) + print("", file=out) + + +def _verify_one( + checkout: Path, + paths: Sequence[str], + read: Callable[[str], str | None], + manifest: Manifest, + dep: dependencies.Dependency, + versions: list[str], + out: TextIO, +) -> bump.Report | None: + """One package, every candidate, each in its own sandbox.""" + from hullwork import trial + from hullwork.sandbox import image as image_module + from hullwork.sandbox.run import Sandbox + + runtime = manifest.runtime + assert runtime is not None # noqa: S101 - refused above, and mypy cannot see that + tests = manifest.tests or "" + source = dep.source + + # Which candidate `verify` is on, so a resolver-backed mover knows what to ask for. + _pending: dict[str, str] = {"version": ""} + + with ExitStack() as stack: + worktree = dispatch_module.prepare_worktree(checkout) + stack.callback(shutil.rmtree, worktree, ignore_errors=True) + + def files_now() -> dict[str, bytes]: + """The declared dependency files as they are in the worktree right now. + + Read per build rather than once: the rewrite happens between the two, and the second + build has to see it — `image.dependency_digest` then makes the tag differ by itself, + which is what turns the second build into a real rebuild. + """ + found: dict[str, bytes] = {} + for path in runtime.dependencies or [source]: + whole = worktree / path + if whole.exists(): + found[path] = whole.read_bytes() + return found + + built: dict[str, str] = {} + # **The commit the source is at, when the source goes into the build at all** (item 182). + # Read once: it is what `image_tag` hashes to decide whether an image can be reused, and the + # source does not move between candidates — only the dependency files do, and those are + # hashed separately by `dependency_digest`. + source_ref = trial.head_sha(checkout) if runtime.install_needs_source else None + + def build_now() -> str | None: + try: + image = image_module.build( + runtime, files_now(), None, + # **Item 113's fix, which this path never inherited** (found by item 182, on + # the first third-party tree it was pointed at). The build context holds the + # declared dependency files and never the source, and three ordinary installers + # read the source anyway: a `requirements.txt` beginning `-e .`, a `Gemfile` + # that says `gemspec`, and `mvn test`. Measured on `encode/httpx`, whose first + # requirement is `-e .[brotli,cli,http2,socks,zstd]`: + # + # ERROR: file:///work does not appear to be a Python project: + # neither 'setup.py' nor 'pyproject.toml' found. + # + # Reported as *your own environment does not build*, which was true of what we + # built and false of the project. Ruby, Java and PHP are on the roadmap as + # stacks whose attempts work; every one of them reaches this the same way. + source=worktree if runtime.install_needs_source else None, + source_ref=source_ref, + ) + except image_module.ImageBuildError as failed: + return str(failed) + built["tag"] = image.tag + return None + + # The baseline image, before anything is rewritten. A failure here is the project's + # environment, not the upgrade's, so it is said as that. + problem = build_now() + if problem is not None: + print(f" {dep.name}: your own environment does not build — {problem}\n", file=out) + return None + + made = {"n": 0} + + def make_box(_version: str) -> bump.Box: + """A box on **whatever image `built` holds right now**. + + Called once per run rather than once per candidate, because the second run has to + happen on the rebuilt image — reusing the first box measures the upgraded project's + suite against the environment it replaced, and reports `clean` for a version that was + never installed. Found by a real Docker run; see item 174. + """ + made["n"] += 1 + # Built from the worktree **as it is now**, which is what makes each run happen in the + # environment its own tree describes. Cheap when nothing changed: the digest is the + # content, so `build` reuses the existing image rather than making another. + build_now() + box = Sandbox(image=built["tag"], worktree=worktree) + stack.callback(box.cleanup) + box.ensure_volume( + f"hullwork-deps-{os.getpid()}-{made['n']}", + # **Item 114's fix, which this path never inherited either** (item 182). Anything + # the build installed under `/work` is erased by the worktree volume unless the + # image goes down first — which is what `vendor/` is for PHP, and the reason that + # item exists. Off unless the project asks, so every other project takes the path + # it took yesterday. + seed_from_image=runtime.install_needs_source, + ) + return box # type: ignore[return-value] + + # How this file is moved, and everything moving it can touch (items 175 and 176). For a + # list the line is the pin; for a resolved graph only the ecosystem's own tool may move it, + # and `touches` is what stops one candidate leaving a widened range behind for the next. + resolver = resolve.resolver_for(source) + mover = None + guarded: tuple[str, ...] = (source,) + if resolver is not None: + guarded = resolve.touches(resolver) + here = [p for p in paths if p.rsplit("/", 1)[-1] in set(resolver.needs)] + + def mover(worktree: Path, _r: resolve.Resolver = resolver) -> str | None: + outcome = resolve.upgrade( + resolver=_r, worktree=worktree, package=dep.name, version=_pending["version"], + present=here, run=resolve.in_a_container, + ) + return None if outcome.ok else f"{outcome.outcome.value}: {outcome.detail}" + + report = bump.verify( + tests=tests, source=source, package=dep.name, + was=dep.version, versions=versions, + make_box=make_box, rebuild=lambda _text: build_now(), + mover=mover, touches=guarded, pending=_pending, + ) + + for answer in report.answers: + print(f" {answer.says}", file=out) + if answer.detail: + for line in answer.detail.splitlines()[:8]: + print(f" {line}", file=out) + print("", file=out) + return report + + +def _cmd_features(args: argparse.Namespace, settings: Settings, out: TextIO) -> int: + """What this can do for your project, and what it cannot. Item 186. + + **The same rules as `projects lanes --checkout .`**, which is the precedent this copies: a + checkout, no credential of any kind, nothing executed, nothing written and no socket opened. It + answers before you have decided anything, which is the only moment the answer is worth having. + + Settings are read for **which variables are set and never for their values**, so this can say + *needs a model credential, and none is configured* while holding none — and can be run by + somebody who has configured nothing at all. + """ + checkout = Path(args.checkout).resolve() + + manifest = None + manifest_path = checkout / MANIFEST_FILENAME + if manifest_path.exists(): + try: + manifest = parse_manifest(manifest_path.read_text(encoding="utf-8"), + source=str(manifest_path)) + except ManifestError as broken: + # Said and carried on. A manifest that does not parse is a fact about this checkout and + # answers half the questions below by itself; refusing here would withhold the other + # half over a file the reader is about to fix anyway. + print(f"{manifest_path} does not parse, so everything it would answer reads as no:\n" + f" {broken}\n", file=out) + + known = features.Checkout( + paths=tuple(_tracked_files(checkout)), + manifest=manifest, + configured=frozenset( + name + for name, present in ( + (features.MODEL_KEY, settings.model_key is not None), + (features.CODE_TOKEN, settings.forge_code_token is not None), + ("origin", _origin_url(checkout) is not None), + ) + if present + ), + ) + + print(f"What Hullwork can do for {checkout.name}, and what it cannot.\n", file=out) + answers = features.examine(known) + for line in features.lines(answers): + print(line, file=out) + + print( + "Every limit above is true whether or not the feature is available — that is what a limit " + "is. Nothing here ran, opened a socket or needed a credential.", + file=out, + ) + if features.INSTANCE_SHAPED: + print( + "\nAnswered by `hullwork doctor` on the instance rather than here, because a checkout " + "cannot know them: " + ", ".join(features.INSTANCE_SHAPED) + ".", + file=out, + ) + return 0 + + def _propose_entry(args: argparse.Namespace, settings: Settings, out: TextIO) -> int: """Standalone with a checkout, database-backed with a repo — same shape as `lanes`. @@ -366,16 +1110,33 @@ def _propose_entry(args: argparse.Namespace, settings: Settings, out: TextIO) -> return _cmd_propose(args, session, settings, out) -def _coordinate_of(checkout: Path) -> str: - """`owner/name` for a local checkout, from its `origin` remote, or a visible placeholder. +def _tracked_files(checkout: Path) -> list[str]: + """The checkout's tracked files, which is what a forge would serve. - A manifest's `git.repo` is validated as `owner/name` (`manifest.py`), so the directory's own - name would produce a proposal that cannot parse — the one thing a proposal must never do, since - its whole purpose is to be committed. The remote is where that coordinate exists locally. + **Tracked rather than walked**, and the reason is the same for both callers: a walk reads + `.venv/` and `node_modules/`, so a proposal would come from a cache and a dependency report + would be about somebody else's dependencies. + """ + listed = subprocess.run( # noqa: S603 + ["git", "-C", str(checkout), "ls-files"], # noqa: S607 + capture_output=True, + text=True, + check=False, + ) + if listed.returncode != 0: + raise CommandError( + f"could not list the files in {checkout}: it is not a git checkout, and this reads " + f"tracked files so what it reports matches what a forge would serve.\n" + f" {listed.stderr.strip()}" + ) + return [line for line in listed.stdout.splitlines() if line] - When there is no usable remote the placeholder is `owner/name` verbatim: it fails validation - loudly and reads as something to replace, which is the same choice as `REPLACE-ME` for - `group_add`. A plausible-looking wrong value would be committed. + +def _origin_url(checkout: Path) -> str | None: + """The `origin` remote's URL, or `None` when there is not one to have. + + One call, two readers: the coordinate below and the forge that holds it (item 171). Asking + git twice for the same string would let the two answers disagree about the same repository. """ url = subprocess.run( # noqa: S603 ["git", "-C", str(checkout), "remote", "get-url", "origin"], # noqa: S607 @@ -383,8 +1144,22 @@ def _coordinate_of(checkout: Path) -> str: text=True, check=False, ) - if url.returncode == 0: - trimmed = url.stdout.strip().removesuffix(".git") + return url.stdout.strip() if url.returncode == 0 else None + + +def _coordinate_from(url: str | None) -> str: + """`owner/name` out of a remote URL, or a visible placeholder. + + A manifest's `git.repo` is validated as `owner/name` (`manifest.py`), so the directory's own + name would produce a proposal that cannot parse — the one thing a proposal must never do, since + its whole purpose is to be committed. The remote is where that coordinate exists locally. + + When there is no usable remote the placeholder is `owner/name` verbatim: it fails validation + loudly and reads as something to replace, which is the same choice as `REPLACE-ME` for + `group_add`. A plausible-looking wrong value would be committed. + """ + if url: + trimmed = url.removesuffix(".git") # `git@host:owner/name` and `https://host/owner/name` both end in the two segments wanted, # and anything else falls through to the placeholder rather than being guessed at. parts = trimmed.replace(":", "/").rstrip("/").split("/") @@ -410,19 +1185,7 @@ def propose_from_local_ci(checkout: Path) -> str | None: `the_recipe_its_toolchain_needs` takes a reader, so neither knows where each came from. Tracked files only, to match what a forge serves — a walk would read `.venv/` and propose from a cache. """ - listed = subprocess.run( # noqa: S603 - ["git", "-C", str(checkout), "ls-files"], # noqa: S607 - capture_output=True, - text=True, - check=False, - ) - if listed.returncode != 0: - raise CommandError( - f"could not list the files in {checkout}: it is not a git checkout, and this reads " - f"tracked files so the proposal matches what a forge would serve.\n" - f" {listed.stderr.strip()}" - ) - paths = [line for line in listed.stdout.splitlines() if line] + paths = _tracked_files(checkout) def read(path: str) -> str | None: try: @@ -430,11 +1193,15 @@ def read(path: str) -> str | None: except OSError: return None + origin = _origin_url(checkout) for candidate in propose.find(paths): text = read(candidate) if text is None: continue - proposal = propose.read(_coordinate_of(checkout), candidate, text) + proposal = propose.read(_coordinate_from(origin), candidate, text) + # Which forge holds this, when the host says so (item 171). Set here rather than passed + # into `read`, which parses CI text and has no business knowing about remotes. + proposal.remote_host = propose.host_of_remote(origin) if proposal.found_anything: checked = propose.only_files_that_exist(proposal, paths) return propose.render( @@ -1348,6 +2115,10 @@ def _cmd_status( } json_merged, json_holding, json_recurred = recurrence.counted(session) payload["attempts"] = outcomes.funnel(session).as_dict() + # Item 183: the parts, so an operator computes their own ratio. Never a percentage here + # either — six samples do not carry that precision, and a number this product publishes + # about itself is the one place that matters most. + payload["desk"] = outcomes.desk(session).as_dict() spent = spend.per_instance( session.query(Attempt).all(), spend.Prices.from_settings(settings) ) @@ -1441,6 +2212,17 @@ def _cmd_status( file=out, ) + # **First, because DR-0017 signed for it** (item 183). Everything below this block has + # *attempts* as its denominator and therefore answers *of the attempts we made, how did they + # go*. This one has **what arrived** as its denominator, which is the question the accepted + # decision says the product is measured by — and it is above the others because a reader who + # stops after one block should have read the one that can embarrass us. + desk_said = outcomes.desk_lines(outcomes.desk(session)) + if desk_said: + print("\n The desk:", file=out) + for line in desk_said: + print(f" - {line}", file=out) + # Item 119, and the same question as the line above at a different distance: that one is about # fixes that landed, this one about what became of every attempt that was made. Counts, never a # percentage — `outcomes` says why, and the two most important numbers in it are the ones a @@ -2810,6 +3592,86 @@ def build_parser() -> argparse.ArgumentParser: proposing.add_argument("--forge", default="forgejo", choices=SUPPORTED_FORGES) proposing.set_defaults(standalone=_propose_entry) + depending = subparsers.add_parser( + "deps", + help="which pinned dependencies have a published vulnerability", + description=( + "Reads the lock files a checkout carries, asks OSV what is published against those " + "exact versions, and prints what came back with the version that ends each one.\n\n" + "Needs no credential of any kind: no forge, no model, no Docker, no database. The one " + "host it contacts is OSV's public API, which takes no key and no account. Lock files " + "rather than declarations, because a declaration is a range and a range does not say " + "what your build resolved to.\n\n" + "It proposes nothing and changes nothing. Whether an upgrade survives your own test " + "suite is a separate question, and answering it is what the sandbox is for.\n\n" + "Your checkout is never written to, by any of these flags: `--verify` and `--fix` work " + "in a copy, and what `--fix` produces is written where you point `--into` — for you to " + "read. Nothing is opened on any forge." + ), + ) + depending.add_argument( + "--checkout", default=".", help="the checkout to read (default: the current directory)" + ) + depending.add_argument( + "--verify", + action="store_true", + help=( + "take each published fix, apply it, and run your own test suite against it in a " + "sandbox — reporting whether the upgrade holds, breaks your suite (naming the tests) " + "or will not install. Needs a hullwork.yml and the Docker daemon; the report without " + "this flag needs neither. No model credential either way: there is no agent in this " + "path." + ), + ) + depending.add_argument( + "--fix", + action="store_true", + help=( + "for the upgrades that break your suite, ask an agent to change your code so they fit " + "— then run your suite again with the upgrade still applied, and check the version is " + "still pinned afterwards. Implies --verify. This is the only part of `deps` that calls " + "a model, so it needs a model credential; it still needs no forge and opens nothing, " + "and what it produced is written to --into for you to read." + ), + ) + depending.add_argument( + "--open", + action="store_true", + help=( + "open a draft pull request for every upgrade that passed your suite — one per package, " + "never a batch, rooted at the commit the runs were made against. Implies --verify. " + "This is the only flag here that writes to your repository, and it needs " + "HULLWORK_FORGE_URL and HULLWORK_FORGE_CODE_TOKEN. Nothing that broke, nothing that " + "was blocked, and nothing whose baseline was red is ever opened." + ), + ) + depending.add_argument( + "--into", + default="hullwork-refits", + help="where to write what the fix attempts produced (default: ./hullwork-refits)", + ) + depending.set_defaults(standalone=_cmd_deps) + + featuring = subparsers.add_parser( + "features", + help="what Hullwork can do for this project, and what it cannot", + description=( + "Reads your checkout and your hullwork.yml and says, feature by feature, whether this " + "instance can do it for you — and when it cannot, which requirement is missing and " + "what to do about it.\n\n" + "Needs no credential of any kind. It runs nothing, opens no socket, starts no " + "container and writes nothing: it is a reading of what you already have, meant to be " + "run before you have decided anything.\n\n" + "Every feature also carries what it cannot do **even when it is available**, because a " + "limit you meet after adopting something is a limit you found the expensive way. " + "Variables are read for whether they are set, never for their values." + ), + ) + featuring.add_argument( + "--checkout", default=".", help="the checkout to read (default: the current directory)" + ) + featuring.set_defaults(standalone=_cmd_features) + laning = actions.add_parser( "lanes", help="show which of this repository's files the instance keeps a human on", diff --git a/hullwork/config.py b/hullwork/config.py index a169ba4..f8c2083 100644 --- a/hullwork/config.py +++ b/hullwork/config.py @@ -91,10 +91,22 @@ class Settings(BaseSettings): #: resolving it quietly. forge_kind: str | None = None - # The credential an agent pushes through, kept apart from the one above on purpose. The - # ingest token is held by the request path and the sweep — it is in memory whenever the - # service is up — so it must never be able to write code. Unset until M2; there is no - # fallback to `forge_token`, because a convenient fallback is how a boundary is lost. + # The credential Hullwork pushes **verified work** through, kept apart from the one above on + # purpose. The ingest token is held by the request path and the sweep — it is in memory + # whenever the service is up — so it must never be able to write code. There is no fallback to + # `forge_token`, because a convenient fallback is how a boundary is lost. + # + # **This used to say "the credential an agent pushes through", and item 178 made that false.** + # `hullwork deps --open` opens a pull request for an upgrade that passed the project's own + # suite, with **no agent having run** — no model, no gateway, no brief. Reusing this token was + # the operator's decision on 2026-08-09, over a third one of its own, and the argument is that + # a third token would need exactly the same scope (`write:repository`): an audit boundary + # rather than a capability boundary, which is not what the split above is. That one is real and + # was measured (item 073, and `credentials.py` records how). + # + # What the rewrite costs, said plainly so nothing goes on relying on it: **nobody may infer + # that a model was called from the fact that something was pushed.** Two paths hold this token + # now — an agent's fix and a verified upgrade — and only the attempt trail can tell them apart. forge_code_token: SecretStr | None = None # The error tracker's READ api (item 036). Optional: without it Hullwork behaves exactly as it diff --git a/hullwork/dependencies.py b/hullwork/dependencies.py new file mode 100644 index 0000000..b3e25f2 --- /dev/null +++ b/hullwork/dependencies.py @@ -0,0 +1,231 @@ +"""What a project pinned, read from its own lock files. Item 172, DR-0016. + +**Lock files rather than declarations**, and the distinction is the whole reason this module can +say anything useful: `requests>=2.0` is a range, and a range does not tell you what is installed. +Only a lock says what a build actually resolved to, which is what a vulnerability database can be +asked about. + +**Pure functions over a `read` callable**, which is `propose`'s pattern and is here for the same +reason: the identical code then serves a local checkout and a forge tree, so the answer cannot +differ depending on which door the question came through. + +**Nothing here reaches the network**, and nothing here needs to. The sandbox has none by design +(`sandbox/image.py` — *"the build has network; the attempt does not"*), so DR-0016 puts this step +in the dispatcher, before any container exists. This module does not even know a container is +coming. +""" + +from __future__ import annotations + +import json +import re +import tomllib +from collections.abc import Callable, Sequence +from dataclasses import dataclass + +#: What this reads, named so a checkout with none of them can be told what was looked for. An empty +#: list reads as "you have no dependencies", which is a different claim. +#: +#: The ecosystem lives in each reader rather than beside the filename: **OSV's own strings** +#: (`npm`, `PyPI`) go straight into the query, and a translation table between our names and +#: theirs would be a second thing to keep correct for no gain. +#: +#: The last two entries are shapes rather than names, and item 180 is why: matching only the exact +#: basename `requirements.txt` missed every layout Python projects actually use. This list is for a +#: person to read; `is_requirements` below is what decides. +WHAT_IS_LOOKED_FOR: tuple[str, ...] = ( + "package-lock.json", + "uv.lock", + "poetry.lock", + "requirements.txt", + "requirements-*.txt and *-requirements.txt", + "any *.txt inside a requirements/ directory", +) + +#: `name==version`, and nothing else. Extras and environment markers are stripped before the match +#: because `httpx[http2]==0.27.0 ; python_version >= "3.8"` pins `httpx`, and refusing to read that +#: line would drop a real pin over punctuation. +_PINNED = re.compile(r"^\s*([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*==\s*([^\s;#]+)") + +#: A requirement line that is not a comment, not blank, and not an option. +#: +#: **The leading `-` is excluded deliberately**, and a test is why: with it in the class, `-e .` +#: counted as a requirement this reader could not pin, so a file whose every real line was pinned +#: still reported an unpinned one. `-e`, `-r other.txt` and `--index-url` are pip options, not +#: dependencies, and no package name begins with a hyphen. +_A_REQUIREMENT = re.compile(r"^\s*[A-Za-z0-9._]") + + +@dataclass(frozen=True) +class Dependency: + """One pinned package, and which file said so. + + `source` is carried because a project can pin the same name in two files at different + versions, and a report that cannot say which one it read is a report nobody can act on. + """ + + ecosystem: str + name: str + version: str + source: str + + +def _from_package_lock(text: str, source: str) -> list[Dependency]: + """npm's lock, versions 2 and 3, which both carry the flat `packages` map. + + The `""` key is the project itself rather than a dependency of it. Including it would have + Hullwork ask OSV about the repository being scanned. + """ + try: + document = json.loads(text) + except ValueError: + return [] + packages = document.get("packages") + if not isinstance(packages, dict): + return [] + + found: list[Dependency] = [] + for path, entry in packages.items(): + if not path or not isinstance(entry, dict): + continue + version = entry.get("version") + # An entry with no version appears in the file and pins nothing — a link or a workspace + # member — so it is not something to ask a vulnerability database about. + if not isinstance(version, str) or not version: + continue + # `node_modules/@scope/pkg` → `@scope/pkg`, and nested paths keep only the last package. + name = path.split("node_modules/")[-1] + found.append(Dependency("npm", name, version, source)) + return found + + +def _from_toml_lock(text: str, source: str) -> list[Dependency]: + """`uv.lock` and `poetry.lock`, which are the same two keys under `[[package]]`. + + One reader rather than two: they differ in everything except the part this needs, and a second + reader would be a second thing to keep correct for a difference that does not exist here. + """ + try: + document = tomllib.loads(text) + except tomllib.TOMLDecodeError: + return [] + packages = document.get("package") + if not isinstance(packages, list): + return [] + + found: list[Dependency] = [] + for entry in packages: + if not isinstance(entry, dict): + continue + name, version = entry.get("name"), entry.get("version") + if isinstance(name, str) and isinstance(version, str) and name and version: + found.append(Dependency("PyPI", name, version, source)) + return found + + +def _from_requirements(text: str, source: str) -> list[Dependency]: + """The weakest reader, and the one most projects will actually hit. + + Only `==` pins. Everything else is a range, and a range cannot be asked about — see + `unpinned`, which is how the caller says how much of the file it could not use. + """ + found: list[Dependency] = [] + for line in text.splitlines(): + matched = _PINNED.match(line) + if matched: + found.append(Dependency("PyPI", matched.group(1), matched.group(2), source)) + return found + + +def unpinned(text: str) -> int: + """How many requirement lines were **not** `==` pins. + + Reporting four packages out of a file with six requirement lines, without saying so, would + understate the answer silently — which is the failure mode this whole repository is about. + """ + return sum( + 1 + for line in text.splitlines() + if _A_REQUIREMENT.match(line) and not _PINNED.match(line) + ) + + +_READERS: dict[str, Callable[[str, str], list[Dependency]]] = { + "package-lock.json": _from_package_lock, + "uv.lock": _from_toml_lock, + "poetry.lock": _from_toml_lock, + "requirements.txt": _from_requirements, +} + +#: `requirements-dev.txt`, `dev-requirements.txt`, `requirements_test.txt` — the word at **one end +#: or the other**, never buried in the middle. +#: +#: The first version of this was `(?:.+[-_])?requirements(?:[-_].+)?\.txt`, whose comment claimed it +#: would not take `install-requirements-guide.txt`. It did: prefix `install-`, the word, suffix +#: `-guide`. The comment was the assertion, and a comment is not one — a test caught it the moment +#: the near miss was written down. So the alternation is explicit: the name *starts* with the word, +#: or it *ends* with it. +_NAMED_REQUIREMENTS = re.compile(r"^(?:requirements(?:[-_].+)?|.+[-_]requirements)\.txt$") + +#: A directory whose whole job is to hold them. `requirements/base.txt` and `requirements/prod.txt` +#: are the layout this repository itself uses, and item 180 found them unread. +_REQUIREMENTS_DIR = "requirements" + + +def is_requirements(path: str) -> bool: + """Whether this path is a pip requirements list. Item 180. + + **Widened from one exact basename, and safe because the reader is strict.** `_from_requirements` + takes only `name==version` lines, so a `.txt` that is not a requirements file contributes zero + dependencies rather than nonsense — which is what makes casting a wider net cost nothing. The + alternative, matching one name, cost this repository three of its own pinned packages in + silence. + + Two shapes, because those are the two conventions: the word in the file name, and a directory + named for it. Anything else with a `.txt` extension is left alone — a report about a changelog + would be worse than the miss. + """ + parts = path.split("/") + name = parts[-1] + if not name.endswith(".txt"): + return False + if len(parts) >= 2 and parts[-2] == _REQUIREMENTS_DIR: + return True + return bool(_NAMED_REQUIREMENTS.match(name)) + + +def _reader_for(path: str) -> Callable[[str, str], list[Dependency]] | None: + """Which reader owns this path, or `None` when nothing does. + + The exact-name table first, because three of the four entries are lock files whose names are + fixed by their own tooling and cannot be pattern-matched without inviting a false positive. + """ + reader = _READERS.get(path.rsplit("/", 1)[-1]) + if reader is not None: + return reader + return _from_requirements if is_requirements(path) else None + + +def read_lockfiles( + paths: Sequence[str], read: Callable[[str], str | None] +) -> list[Dependency]: + """Every pinned dependency the tree declares, in the order the files were found. + + A file that cannot be parsed contributes nothing rather than raising: a project with a broken + `requirements.txt` still has a `package-lock.json` worth reading, and one unreadable file must + not cost the whole answer. + """ + found: list[Dependency] = [] + for path in paths: + # Matched on the file name so a lock in a subdirectory is read too — a monorepo pins per + # package, and only reading the root would report a fraction of the truth as the whole. + # Item 180: that same sentence is why `requirements/prod.txt` is read as well, and it took + # running this against its own repository to notice that it was not. + reader = _reader_for(path) + if reader is None: + continue + text = read(path) + if text is None: + continue + found.extend(reader(text, path)) + return found diff --git a/hullwork/dispatch.py b/hullwork/dispatch.py index 2ea6d9a..db53873 100644 --- a/hullwork/dispatch.py +++ b/hullwork/dispatch.py @@ -26,6 +26,7 @@ import logging import shutil import tempfile +from collections.abc import Callable, Sequence from dataclasses import dataclass, field from pathlib import Path @@ -39,6 +40,7 @@ RunResult, Sandbox, collect_changes, + created_test_config, is_test_infrastructure, snapshot, ) @@ -83,6 +85,13 @@ class Verdict: #: Test infrastructure the fix phase had changed and this module put back (item 046). Non-empty #: means the published claim rests on the second green gate, not the first. restored: str = "" + #: Dependency files the fix phase had changed and `refit` put back (item 179). Non-empty means + #: the phase reached for the one shortcut that would have passed every gate. + reverted: str = "" + #: The headline sentence, when this sequence's claim is not the ordinary one. Empty means + #: `evidence` chooses from the outcome, which is right for every attempt that starts from a + #: reproducing test somebody wrote. A refit does not: see `refit` below. + claim: str = "" def dispatch( @@ -326,6 +335,297 @@ def dispatch( ) +#: The claim a refit publishes under, and it is deliberately not `evidence`'s ordinary one. +#: +#: *A test that failed against unmodified code passes with this change applied* is false here in the +#: one word that carries it: the code **was** modified — by the upgrade — before the first gate ran. +#: A headline that says otherwise would overclaim in exactly the direction item 171 removed, and it +#: would be the artefact rather than the run that lied. +#: It does not quote the sentence it replaces, deliberately: a headline that names the claim it is +#: *not* making reads as that claim to somebody skimming, and it is the line a reviewer acts on. +REFIT_CLAIM = ( + "**The tests this upgrade broke pass with the change below, and the upgrade is still " + "applied.** The code here had already been changed before the first run — by the upgrade " + "itself — so this is not the ordinary red-green claim. Both runs are below with their exit " + "codes, and the pinned version was read back out of the tree after the second one." +) + +#: And the four ways a refit ends without one, each with its own sentence. +#: +#: `evidence._claim` chooses from the outcome, and every one of its sentences is about *a bug* — the +#: bug was reproduced, the bug could not be reproduced, this appears to be fixed already. None of +#: those is what happened here, and an artefact that calls a dependency upgrade a bug sends its +#: reader looking for one. Carried on the verdict rather than inferred at the far end, so the two +#: surfaces cannot come to disagree about what a run was. +REFIT_NOT_BROKEN = ( + "**This upgrade does not break your suite here, so there was nothing to fix.** The suite was " + "run with the new version applied and it passed. No agent was called." +) +REFIT_REVERTED = ( + "**The attempt reverted the upgrade instead of making the code fit it, and that is not a " + "fix.** Putting the old version back makes a suite pass and undoes what this work exists to " + "possible. Hullwork put the dependency files back and published nothing." +) +REFIT_NOT_FIXED = ( + "**The tests this upgrade broke still fail.** Nothing was merged and nothing was hidden; what " + "was tried is below, with both runs and their exit codes." +) +REFIT_NO_CHANGE = ( + "**No change was produced, so there is nothing to check.** The upgrade still breaks the tests " + "named below." +) + + +def refit( + session: object, + item: Item, + manifest: Manifest, + engine: Engine, + *, + box: Sandbox, + attempt: Attempt, + package: str, + to: str, + guarded: Sequence[str], + version_now: Callable[[Path], str | None], +) -> Verdict: + """Make a broken upgrade fit. Item 179, DR-0018 step 4. + + ``` + 0. RED GATE run `tests` with the upgrade already applied → must FAIL + 1. refit agent; source editable, dependencies read-only → the change + 2. GREEN GATE run `tests` again, upgrade still applied → must PASS + 3. the re-read what the tree pins now → must be `to` + ``` + + **The red gate is free and it is inverted.** In `dispatch` above, red means the candidate test + reproduces the bug; here it is the starting condition, and the failing tests are the project's + own — failing against a version somebody published, with nobody having authored them for the + occasion. DR-0003's expensive half is therefore already satisfied by evidence no model can + flatter, which is why this sequence is three steps rather than six. + + **The whole correctness of this function is that the dependency files are read-only to step 1.** + Reverting is the obvious cheat and it is the most plausible-looking false artefact this product + could ever emit: put the old version back and the suite goes green, red before, green after, + upgrade gone. + + Measured while building this, and it changes what the guard is *for*: within one attempt a + revert **cannot buy a green gate**. The image is built before this function is called and the + phases have no network, so the installed version cannot move whatever the files say. What a + revert buys is the *published diff* — a pull request that undoes the upgrade it claims to make + possible, with two honest gate runs attached. So the files are restored before anything is + collected, and the verdict says what was attempted. + + `version_now` is the backstop and it is a different guard, not a second copy of the first: the + restore covers the files this ecosystem's resolver is known to touch (`resolve.touches`), and + the re-read covers everything it does not — a pin moved somewhere nobody taught the guard about, + a vendored dependency, an ecosystem added later. A green gate whose tree no longer carries the + upgraded version is a revert however it got there. + """ + worktree = box.worktree + tests = (manifest.tests or "").strip() + lint_ask = (manifest.lint or "") if "lint" in manifest.autofix.gates else "" + if not tests: # pragma: no cover - the manifest parser refuses this before we get here + raise Abandoned("the manifest declares no test command") + + # --- step 0: the red gate, already paid for ----------------------------------------------- + red = _run_gate(session, attempt, box, AttemptPhase.RED_GATE, tests) + if red.ok: + # Before the model is called. The breakage does not reproduce here, so there is nothing to + # fix and nothing was learned about the upgrade that `deps --verify` had not already said. + return Verdict( + AttemptOutcome.NOT_REPRODUCIBLE, + AttemptPhase.RED_GATE, + claim=REFIT_NOT_BROKEN, + detail=( + f"the suite passes with {package} {to} applied, so there is nothing here to fix. " + f"Whatever broke when this upgrade was measured does not break now." + ), + ) + + # Snapshotted after the gate, not before: a real test run writes caches, and taken earlier every + # one of those looks like something the agent produced (the `.pytest_cache` finding, item 025). + pristine = snapshot(worktree) + + # --- step 1: the agent, with the dependencies out of reach -------------------------------- + _run_agent( + session, attempt, box, engine, Phase.REFIT, + test_path=manifest.test_path, lint=lint_ask, + ) + reverted = _restore_dependencies(worktree, pristine, guarded) + try: + changes = collect_changes(worktree, pristine) + except UnsafePathError as exc: + return Verdict( + AttemptOutcome.FAILED, + AttemptPhase.FIX, + reverted=reverted, + claim=REFIT_NOT_FIXED, + detail=f"the fix phase produced something it may not: {exc}", + ) + if not changes: + # Two different findings, and they must not share a sentence: a phase that did nothing and + # a phase that did the one forbidden thing are not the same report to a person. + return Verdict( + AttemptOutcome.FAILED, + AttemptPhase.FIX, + reverted=reverted, + claim=REFIT_REVERTED if reverted else REFIT_NO_CHANGE, + detail=( + f"the fix phase reverted the upgrade instead of making the code fit it " + f"({reverted}), and changed nothing else. Those files were put back, so there is " + f"no change here — a suite made green by putting the old version back is a revert, " + f"not a fix." + if reverted + else "the fix phase changed nothing, so there is no fix to check" + ), + ) + + # --- step 2: the green gate --------------------------------------------------------------- + green = _run_gate(session, attempt, box, AttemptPhase.GREEN_GATE, tests) + + # Item 046, unchanged: a suite that collects nothing passes trivially, and the *difference* + # between the two runs is the finding rather than either one of them. + restored = _restore_infrastructure(worktree, pristine) + if restored: + try: + changes = collect_changes(worktree, pristine) + except UnsafePathError as exc: # pragma: no cover - the earlier collection raises first + return Verdict( + AttemptOutcome.FAILED, + AttemptPhase.GREEN_GATE_RESTORED, + reverted=reverted, + claim=REFIT_NOT_FIXED, + detail=f"the restored tree could not be read back: {exc}", + ) + regated = _run_gate(session, attempt, box, AttemptPhase.GREEN_GATE_RESTORED, tests) + if not regated.ok: + gamed = ( + ", so it passed only because it had disabled the tests" + if green.ok + else " and the suite does not pass either way" + ) + return Verdict( + AttemptOutcome.FAILED, + AttemptPhase.GREEN_GATE_RESTORED, + changes=changes, + restored=restored, + reverted=reverted, + claim=REFIT_NOT_FIXED, + detail=( + f"the fix modified test infrastructure it may not have ({restored}) and the " + f"suite fails once that is put back{gamed}" + ), + ) + elif not green.ok: + return Verdict( + AttemptOutcome.FAILED, + AttemptPhase.GREEN_GATE, + changes=changes, + reverted=reverted, + claim=REFIT_NOT_FIXED, + detail=( + f"the suite still does not pass with {package} {to} applied" + + ( + f", and the fix phase had put the old version back ({reverted}) — that was " + f"undone before this run, because a revert is not a fix" + if reverted + else "" + ) + ), + ) + + # --- step 3: what does the tree pin now? --------------------------------------------------- + landed = version_now(worktree) + if landed != to: + # A green gate this side of a revert is the one artefact this whole item exists to prevent. + # Reported as what it is, and never as a fix, whichever route got it here. + return Verdict( + AttemptOutcome.FAILED, + AttemptPhase.GREEN_GATE, + changes=changes, + restored=restored, + reverted=reverted or ", ".join(guarded), + claim=REFIT_REVERTED, + detail=( + f"the suite passes and the tree no longer pins {package} at {to}: it " + + (f"pins {landed}" if landed else "no longer pins it at all") + + f". That is a revert rather than a fix — the upgrade this was supposed to make " + f"possible is gone, and a green suite without it proves nothing about {to}." + ), + ) + + # --- step 4: lint, only if the manifest names the gate (item 067) -------------------------- + lint_failed = "" + if "lint" in manifest.autofix.gates and manifest.lint: + lint = _run_gate(session, attempt, box, AttemptPhase.LINT_GATE, manifest.lint) + if not lint.ok: + lint_failed = ( + f"The gate that failed is `{manifest.lint}`, run against the change below.\n" + f"{failing_lines(lint.output) or lint.output[-2000:]}" + ) + + note = "" + if reverted: + # Led with, not tucked away, for item 067's reason: an artefact whose shape hides its own + # weakest part is worse than none. The claim below still stands — the gate ran with the + # upgrade in place — but a reviewer has to know the phase reached for the shortcut. + note += ( + f"The fix phase also edited dependency files it may not have ({reverted}). They were " + f"put back before the run below, so the change published here contains none of them " + f"and the suite passed with {package} {to} still applied.\n\n" + ) + if restored: + note += ( + f"This fix had also modified test infrastructure it may not have ({restored}); those " + f"files were put back and the suite was run again, so the claim rests on that second " + f"run and the published change does not contain them.\n\n" + ) + return Verdict( + AttemptOutcome.PR_OPEN_LINT_FAILED if lint_failed else AttemptOutcome.PR_OPEN, + AttemptPhase.PUBLISH, + changes=changes, + restored=restored, + reverted=reverted, + claim=REFIT_CLAIM, + detail=(f"{lint_failed}\n\n" if lint_failed else "") + note + ( + f"this makes {package} {to} possible: the tests below failed with it applied and pass " + f"with the change, and {package} is still pinned at {to} in the tree those runs used." + ), + ) + + +def _restore_dependencies( + worktree: Path, pristine: dict[str, bytes], guarded: "Sequence[str]" +) -> str: + """Put every dependency file back exactly as the upgrade left it, and say which had moved. + + **Restoring rather than merely detecting**, for `_restore_candidate`'s reason one file over: a + reverted pin that reached `collect_changes` would be published, and the pull request would undo + the upgrade in its own diff while its body claimed to make it possible. + + Three ways a file can move and all three are a revert: changed, deleted, and — the one that is + easy to leave out — **created where there was none**. A project pinning in `requirements.txt` + with no `package.json` beside it can have one written, and a guard that only compared existing + files would not see it. + """ + moved: list[str] = [] + for name in guarded: + target = worktree / name + original = pristine.get(name) + if original is None: + if target.exists(): + moved.append(name) + target.unlink() + continue + if target.exists() and target.read_bytes() == original: + continue + moved.append(name) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(original) + return ", ".join(sorted(moved)) + + #: How many failure lines a verdict carries. A suite with 2000 failures must not become the verdict, #: and after a dozen the operator has the shape of it — the rest is in `attempt_steps.output`. FAILURES_SHOWN = 12 @@ -524,6 +824,13 @@ def _restore_infrastructure(worktree: Path, pristine: dict[str, bytes]) -> str: The scope comes from `is_test_infrastructure`, which belongs to the instance. It deliberately is not the manifest's `test_path`: that field arrives from the watched repository, and it already pulls the other way — narrowing it tightens the reproduce-phase guard while loosening this one. + + **And configuration that was invented rather than edited is removed** (item 179). Iterating the + snapshot alone left a hole this guard's own sentence describes: a phase that *creates* a root + `conftest.py` where the project had none switches the suite off, is absent from `pristine`, and + was therefore never touched — leaving `restored` empty, so the second gate never ran and the + attempt published with the mechanism in its diff. Measured against this function before + `created_test_config` existed. """ restored: list[str] = [] for path, original in pristine.items(): @@ -535,6 +842,11 @@ def _restore_infrastructure(worktree: Path, pristine: dict[str, bytes]) -> str: restored.append(path) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(original) + for path in created_test_config(worktree, pristine): + # Removed rather than restored: there is nothing to put back, and leaving it while + # reporting it would publish the file that decided the gate. + restored.append(path) + (worktree / path).unlink() return ", ".join(sorted(restored)) @@ -563,8 +875,24 @@ def build_brief_file(session: object, item: Item, contract_dir: Path) -> Path: Into the contract directory, not the worktree. The brief is Hullwork's input to the agent, not a change to the repository, and putting it in the tree made it look like one. """ + return write_brief(brief.build(session, item), contract_dir) # type: ignore[arg-type] + + +def write_brief(text: str, contract_dir: Path) -> Path: + """The same file, from text somebody else composed. Item 179. + + Split out because a refit's brief cannot come from `brief.build`: that one answers *what + Hullwork knows about this error* from the tracker and this instance's history, and a dependency + upgrade has no error, no fingerprint from a stranger and no occurrence count. Built from it, the + brief would open by saying the full event was never fetched — true of a tracker nobody asked, + and misleading about work whose evidence is better than any tracker's. + + What stays shared is everything about *where* it goes and how, because that half has been wrong + twice: in the worktree it looked like a change to the repository, and without the mode the + container could not read it. + """ contract_dir.chmod(CONTRACT_DIR_MODE) target = contract_dir / Path("brief.md") - target.write_text(brief.build(session, item), encoding="utf-8") # type: ignore[arg-type] + target.write_text(text, encoding="utf-8") target.chmod(0o644) return target diff --git a/hullwork/engine.py b/hullwork/engine.py index d81eaab..b2923ca 100644 --- a/hullwork/engine.py +++ b/hullwork/engine.py @@ -40,6 +40,13 @@ class Phase(StrEnum): REPRODUCE = "reproduce" FIX = "fix" + #: Item 179, DR-0018 step 4. The same job as `FIX` and a different thing to say, which is why + #: it is a phase rather than a flag on that one: `fix` opens by naming a reproducing test file + #: at `${TEST_PATH}/${TEST_FILE}`, and in a refit nobody wrote one — the failing tests are the + #: project's own, failing against an upgrade that is already applied. An agent told to look for + #: evidence that does not exist spends the item's one attempt on a misunderstanding, which is + #: what item 094 measured when the filename was wrong rather than absent. + REFIT = "refit" @dataclass(frozen=True) @@ -324,6 +331,23 @@ def parse(cls, text: str) -> "AgentReport": evidence, and a test the fix was allowed to edit proves nothing. Run the whole suite before you finish: every other test must still pass." ;; + refit) + # Item 179. The brief names the upgrade and the tests it broke; this says what the job is and + # what the one forbidden shortcut is. Stated here as well as enforced by the dispatcher, + # because an agent that reverts and is then reported for it has spent the item's one attempt + # on something it was never told not to do. + ASK="A dependency upgrade has ALREADY been applied to this checkout, and it broke tests that +were passing. The brief names the package, both versions, and the tests that now fail. + +Change this project's own source code so those tests pass with the new version. Run the whole +suite before you finish: every other test must still pass. + +Do NOT edit any dependency file — the pins, the manifest and the lock are read-only evidence +here. Putting the old version back would make the suite pass and is not a fix: Hullwork restores +those files, re-reads the version out of the tree, and reports that as a revert rather than as a +fix. If the upgrade genuinely cannot be made to work, change nothing and say so — that is a +correct and useful answer.${LINT_ASK}" + ;; *) echo "unknown phase $PHASE" >&2; exit 2 ;; esac diff --git a/hullwork/evidence.py b/hullwork/evidence.py index 8321a03..9af844b 100644 --- a/hullwork/evidence.py +++ b/hullwork/evidence.py @@ -20,7 +20,7 @@ import json import logging -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any from hullwork import spend, testoutput @@ -28,6 +28,11 @@ from hullwork.scrub import Scrubber if TYPE_CHECKING: + # Imported for types only: `bump` and `osv` are about producing a verdict and this module is + # about rendering one, and a runtime import would make the renderer a dependency of the + # measurement rather than the other way round. + from hullwork.bump import Answer as BumpAnswer + from hullwork.osv import Advisory from hullwork.spend import Prices log = logging.getLogger(__name__) @@ -128,13 +133,21 @@ def _scrubber(secrets: list[str] | None = None) -> Scrubber: ) -def _claim(attempt: Attempt) -> str: +def _claim(attempt: Attempt, given: str = "") -> str: """The headline sentence, chosen from what the attempt actually did. `failed` at the red gate means the reproduction was refused; `failed` after it means the reproduction stood and the fix did not. The reader a comment is for cannot see the phase table first — the headline is what they act on, so it is the part that must not overstate. + + **`given` is a sequence that does not make this sequence's claim** (item 179). Every sentence + below is about *a bug* — reproduced, not reproduced, already fixed — and a refit is about a + dependency upgrade that was applied before the first gate ran. Its own claim travels on the + verdict rather than being inferred from the outcome here, because a headline chosen at this end + from an outcome that means something else is how an artefact comes to contradict its own table. """ + if given: + return given outcome = attempt.outcome or AttemptOutcome.ABANDONED if outcome is AttemptOutcome.FAILED: if attempt.phase_reached in _BEFORE_FIX: @@ -154,6 +167,10 @@ def pull_request_body( secrets: list[str] | None = None, #: What the operator pays, for the cost row. `None` prints tokens and no money (item 133). prices: "Prices | None" = None, + #: The headline, when the sequence that produced this does not make the ordinary claim + #: (item 179). Empty means it is chosen from the outcome, which is right for every attempt + #: that starts from a reproducing test somebody wrote. + claim: str = "", ) -> str: """The body of the draft pull request. @@ -163,7 +180,7 @@ def pull_request_body( """ scrub = _scrubber(secrets) lines = [ - _claim(attempt), + _claim(attempt, claim), "", ] if detail: @@ -201,6 +218,105 @@ def pull_request_body( return body +#: The sentence that keeps a green pull request honest, and the reason it is not a guarantee. +#: +#: **A green pull request is the easiest place in this product to overclaim.** The reviewer is being +#: asked to merge, the diff is one line, and every other tool that put it there was guessing — so +#: whatever this says will be read generously. +#: +#: **One author for the caveat, which is item 098's rule and was broken here first.** The first +#: version of this said *what was measured is your suite… not that the upgrade is safe or that it +#: fixes anything*, directly beneath `Answer.says`, which already ends *that is what was measured — +#: not that the upgrade is safe, and not that it fixes anything your suite does not exercise*. Two +#: paragraphs, same caveat, different words, at the top of the document a person acts on. Read +#: rather than tested: the assertions were all satisfied. Item 098 records what that costs — "the +#: first paragraph of `acme!9` said it twice and read like a program that had lost its place". +#: +#: So this carries only what the claim above does **not**: the mechanism. A suite that never touches +#: the dependency produces this exact document, and that is a thing a reviewer can check about their +#: own repository rather than a limit they are asked to hold in mind. +WHAT_WAS_MEASURED = ( + "The suite that was run is **yours**, which is what makes the sentence above worth something " + "and also what bounds it: if your tests never exercise this dependency, they go green without " + "ever loading the new version, and this page would read exactly the same. Nothing here " + "inspected the change itself." +) + + +def dependency_pull_request_body( + answer: "BumpAnswer", + advisories: "Sequence[Advisory]" = (), + *, + secrets: list[str] | None = None, +) -> str: + """The body of a pull request for an upgrade that passed. Item 178, DR-0018 step 3. + + **Three facts and no more**, because this is the artefact whose whole value is that it is + narrower than a competitor's: the advisory and where to read it, the claim in DR-0016's exact + wording, and the two runs with their exit codes and the runner's own summary lines. + + The claim is `Answer.says` rather than a sentence written here, and that is the point of it + being a property on the answer: the terminal report prints the same string, so the two surfaces + cannot come to disagree about what was measured. A second rendering that happens to match today + is a rendering that will not match after the first edit to either. + + **Scrubbed like everything else that leaves the instance** (item 027): the summary lines are + captured output of an arbitrary command, and a suite that prints an environment dump on failure + is not a rare event. + """ + scrub = _scrubber(secrets) + lines = [answer.says, "", WHAT_WAS_MEASURED, ""] + + if advisories: + lines += ["### What is published against the version you have", ""] + for advisory in advisories: + summary = f" — {scrub.text(advisory.summary)}" if advisory.summary else "" + lines.append(f"- [{advisory.id}]({advisory.url}){summary}") + lines.append("") + + runs = answer.runs + if runs is not None: + # Both runs in one table, because the *pair* is the evidence and a reader comparing two + # sections separated by a page is a reader who will read one of them. + lines += [ + "### The two runs", + "", + f"Command: `{scrub.text(runs.command)}`", + "", + "| | exit | what the runner said |", + "|---|---|---|", + f"| Before this change | `{runs.before_exit}` | " + f"{_summary_cell(runs.before_summary, scrub)} |", + f"| With `{answer.package} {answer.to}` | `{runs.after_exit}` | " + f"{_summary_cell(runs.after_summary, scrub)} |", + "", + ] + + lines += [ + "---", + "", + "Opened by Hullwork as a **draft**, after running the two commands above. Nobody merges " + "this but you.", + ] + body = "\n".join(lines) + if len(body) > MAX_BODY_CHARS: # pragma: no cover - three facts do not reach 60,000 characters + body = body[:MAX_BODY_CHARS] + "\n\n… [body truncated by Hullwork]" + return body + + +def _summary_cell(summary: str, scrub: Scrubber) -> str: + """The runner's own line, or the fact that it printed none. + + **Never silence**, which is `_what_was_checked`'s rule for the same reason: a cell left out + reads as *the suite said nothing*, and that is a claim nobody made. What happened is that this + runner does not print a line in a shape Hullwork reads, and the exit code beside it is + unaffected. + """ + if not summary.strip(): + return "this runner prints no summary line Hullwork reads — the exit code is the claim" + return f"`{scrub.text(summary)}`" + + def issue_comment( item: Item, attempt: Attempt, @@ -209,6 +325,8 @@ def issue_comment( secrets: list[str] | None = None, #: For the cost row (item 133). prices: "Prices | None" = None, + #: The headline, for a sequence with its own (item 179). See `pull_request_body`. + claim: str = "", ) -> str: """What goes on the issue when there is no pull request. @@ -217,7 +335,7 @@ def issue_comment( only exists in a database is one nobody acts on. """ scrub = _scrubber(secrets) - lines = [_claim(attempt), ""] + lines = [_claim(attempt, claim), ""] if detail: lines += [scrub.text(detail), ""] lines += [ diff --git a/hullwork/features.py b/hullwork/features.py new file mode 100644 index 0000000..3bbeda6 --- /dev/null +++ b/hullwork/features.py @@ -0,0 +1,369 @@ +"""What this can do for your project, and what it cannot. Item 186. + +**Hullwork is modular and never said so.** `hullwork.yml` is the switchboard and almost everything +is off by default — `autofix.agent: none`, no `lint`, `notify.channel: none`, +`autofix.unmatched: human`, `runtime.install: none`. A project that declares nothing gets filing and +nothing else, deliberately. + +What did not exist is the other half of the operator's framing: **each feature has its +limitations**. Nothing declared what it needed or what it could not do, so every limitation was +found by walking into it — and three items measured what that costs. Item 182 found a **false +verdict** produced for a project whose image Hullwork does not build; item 184 found a missing +credential reported after four container builds, as a traceback; item 185 found `propose` writing a +manifest under which nothing could be measured, silently. + +Each was fixed where it happened, and none of those is a place a person looks *before* deciding +whether this is for them. + +**The shape is `projects lanes --checkout .`**, which prints this instance's lane policy against +your tree with no credential of any kind, because *"a policy nobody has read is a policy nobody has +agreed to"*. Same rules here: a checkout, no credential, nothing executed, nothing written and no +socket opened. + +**Declaring a limitation is not accepting it**, and this module must never read as an excuse. That +dependency verification needs Hullwork to build the image is a real limit *and* a consequence of +building on the path DR-0007 demoted to sugar. Saying so is honest; leaving it there is a decision +nobody has taken. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field + +from hullwork.manifest import Manifest + + +@dataclass(frozen=True) +class Need: + """One thing a feature requires, and what to do when it is missing. + + `met` is asked of the checkout and the manifest and **nothing else**: no network, no daemon, no + credential. A requirement this cannot answer from those two is not a requirement this module + states — it is `doctor`'s, and `INSTANCE_SHAPED` names those rather than guessing at them. + """ + + what: str + #: What to do about it, in the words a person would type. Never "configure it correctly". + fix: str + met: Callable[[Checkout], bool] + + +@dataclass(frozen=True) +class Feature: + """One thing Hullwork can do, what it needs, and what it cannot do even when it can. + + **Two fields and they must not blend.** `needs` is checkable and either met or not; `limits` is + true whatever the answer, and is stated either way. The second is the half the operator's + framing names and the half that had no home anywhere in this repository. + + Declared as data, like `resolve.RESOLVERS` and `image.INSTALL_COMMANDS`, so a feature is a row. + """ + + name: str + #: One line, for somebody deciding whether they want it. + does: str + needs: tuple[Need, ...] = () + #: What the **project** must have permitted, as opposed to what it must be able to do (DR-0019). + #: Unmet here is a third answer and not a fourth kind of missing part: *available, and this + #: project has not said yes*. Empty for every feature that writes nothing to a repository. + permits: tuple[Need, ...] = () + #: What it cannot do with everything in place. **Never empty**: a feature with no limits has to + #: say that in words, because an empty list reads as "nobody wrote them down" — which, until + #: this module, was true of all of them. + limits: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Checkout: + """Everything this module is allowed to look at. Deliberately three fields. + + A checkout's tracked paths, its manifest if it has one, and **which instance variables are + set** — never their values. That last one is why this can say *"needs a model credential, and + none is configured"* without holding one, and why it can be run by somebody who has configured + nothing at all. + """ + + paths: tuple[str, ...] = () + manifest: Manifest | None = None + #: Names of environment variables that have a value. Names only, never values. + configured: frozenset[str] = field(default_factory=frozenset) + + def has(self, *names: str) -> bool: + """Whether the checkout tracks a file with one of these base names.""" + wanted = set(names) + return any(path.rsplit("/", 1)[-1] in wanted for path in self.paths) + + +def _manifest_says(check: Callable[[Manifest], bool]) -> Callable[[Checkout], bool]: + """A need that is about the manifest. False when there is none, which is the honest answer.""" + + def met(checkout: Checkout) -> bool: + return checkout.manifest is not None and check(checkout.manifest) + + return met + + +def _installs_from_a_pinned_file(manifest: Manifest) -> bool: + """Whether an upgrade could reach the environment the suite runs in. Item 182's finding. + + With `install: none` the image is `runtime.base` exactly as it comes and nothing is installed + from a lock file, so rewriting a pinned version changes nothing the suite would run against. + Measured: a checkout pinning `jinja2==2.4.1`, a base carrying 3.0.0, and a verdict saying the + suite passed before the change and after it — about a version never installed. + """ + runtime = manifest.runtime + return runtime is not None and runtime.install != "none" and bool(runtime.dependencies) + + +#: What a lock file is called, borrowed from the reader rather than restated — a second list is a +#: second thing to keep correct, and this one would go stale the day an ecosystem is added. +def _pins_anything(checkout: Checkout) -> bool: + from hullwork import dependencies + + return any( + path.rsplit("/", 1)[-1] in {"package-lock.json", "uv.lock", "poetry.lock"} + or dependencies.is_requirements(path) + for path in checkout.paths + ) + + +#: The variable each credential-shaped need looks for. Names, never values (`Checkout.configured`). +MODEL_KEY = "HULLWORK_MODEL_KEY" +CODE_TOKEN = "HULLWORK_FORGE_CODE_TOKEN" # noqa: S105 - a variable's name, never its value + +#: Features whose answer is about an **instance** rather than about a checkout, named rather than +#: guessed at. `doctor` owns these: whether the forge answers, whether the tracker is reachable, +#: whether the database has a schema, whether a dispatcher holds the lease. A checkout cannot know +#: any of it, and a report that pretended to would be worse than one that says whose question it is. +INSTANCE_SHAPED: tuple[str, ...] = ( + "filing a production error as an issue", + "the daily page", + "notifications", + "the recurrence watch", +) + +FEATURES: tuple[Feature, ...] = ( + Feature( + name="dependency report", + does="says which of your pinned versions have a published advisory, and what fixes each", + needs=( + Need( + what="a lock file or a pinned requirements file, committed", + fix="commit one, or pin with `==` — a declaration is a range and a range is not a " + "fact about what your build resolved to", + met=_pins_anything, + ), + ), + limits=( + "It reads what you pinned, so a dependency your build resolves at install time is " + "invisible to it.", + "It asks OSV, which is one database. An advisory nobody published is an advisory this " + "cannot know about.", + ), + ), + Feature( + name="dependency verification", + does="applies each published fix and runs your own suite against it, in a sandbox", + needs=( + Need( + what="a lock file or a pinned requirements file, committed", + fix="commit one, or pin with `==`", + met=_pins_anything, + ), + Need( + what="a hullwork.yml naming an image (`runtime.base`) and your test command", + fix="`hullwork propose --checkout .` writes one from your CI configuration", + met=_manifest_says( + lambda m: m.runtime is not None and bool(m.runtime.base) and bool(m.tests) + ), + ), + Need( + what="an installer that reads the file your versions are pinned in", + fix="name `runtime.install` and the file in `runtime.dependencies`. **Keep your " + "own image as the `base`** — this is one line on top of it, in your own words, not " + "a rebuild from scratch. Without it the image is your base exactly as it comes, so " + "changing a pin changes nothing your suite would run against", + met=_manifest_says(_installs_from_a_pinned_file), + ), + ), + limits=( + "What is measured is **your suite**. If it does not exercise the dependency it stays " + "green without ever loading the new version, and the verdict would read the same.", + "A phase has no network, so a suite that reaches the internet cannot be run here, and " + "is reported as a suite that does not pass rather than as a verdict about the upgrade.", + "It can only measure an upgrade it can **install**, so the image has to be refreshed " + "from the file that pins. That does not mean Hullwork must build your image: your own " + "image as the base, plus the one line that reinstalls your dependencies, is measured " + "the same way (item 188).", + ), + ), + Feature( + name="fixing an upgrade that breaks your suite", + does="asks an agent to change your code so the upgrade fits, then runs your suite again", + needs=( + Need( + what="everything dependency verification needs", + fix="see above — nothing is fixed until something has been measured breaking", + met=lambda c: _pins_anything(c) + and _manifest_says(_installs_from_a_pinned_file)(c), + ), + Need( + what="a model credential on the instance that runs it", + fix=f"set {MODEL_KEY} to an API key from any provider (DR-0004)", + met=lambda c: MODEL_KEY in c.configured, + ), + Need( + what="`autofix.agent` naming an engine this instance holds", + fix="set `autofix: {agent: claude-code}` — it is `none` by default, which is the " + "whole product for a project that wants nothing else", + met=_manifest_says(lambda m: m.autofix.agent != "none"), + ), + ), + limits=( + "One attempt per upgrade and then a person (DR-0003). A failure does not buy a second.", + "It may not touch your dependency files, so an upgrade that can only be made to fit by " + "changing the pin is reported as a revert rather than attempted.", + "It writes what it produced to disk and opens nothing anywhere.", + ), + ), + Feature( + name="opening the upgrades that pass", + does="opens one draft pull request per package whose suite passed, and never any other", + needs=( + Need( + what="everything dependency verification needs", + fix="see above — nothing is opened that was not run", + met=lambda c: _pins_anything(c) + and _manifest_says(_installs_from_a_pinned_file)(c), + ), + Need( + what="a credential able to write to your repository", + fix=f"set {CODE_TOKEN}. It is the one thing here that writes anything anywhere", + met=lambda c: CODE_TOKEN in c.configured, + ), + Need( + what="an `origin` remote, so the repository can be named", + fix="add one — a coordinate cannot be guessed from a directory name, and a wrong " + "guess opens a pull request somewhere else", + met=lambda c: "origin" in c.configured, + ), + ), + permits=( + Need( + what="`autofix.open_upgrades`, which this project has not set", + fix="set `autofix: {open_upgrades: true}` in hullwork.yml. It is false by default " + "because having the credential is not the same as having agreed (DR-0019), and " + "this is the only thing here that writes to your repository", + met=_manifest_says(lambda m: m.autofix.open_upgrades), + ), + ), + limits=( + "Only what passed. Nothing that broke, nothing blocked, no suite that was already red.", + "One pull request per package, never a batch — a grouped upgrade that breaks cannot be " + "bisected without undoing the work.", + "Every one is a draft. Nothing here merges anything, ever (constitution principle 1).", + ), + ), + Feature( + name="fixing a production error", + does="reproduces a reported error with a failing test, fixes it, and opens a draft pull " + "request carrying both", + needs=( + Need( + what="a hullwork.yml naming an image and your test command", + fix="`hullwork propose --checkout .` writes one from your CI configuration", + met=_manifest_says( + lambda m: m.runtime is not None and bool(m.runtime.base) and bool(m.tests) + ), + ), + Need( + what="`autofix.agent` naming an engine this instance holds", + fix="set `autofix: {agent: claude-code}` — `none` is the default", + met=_manifest_says(lambda m: m.autofix.agent != "none"), + ), + Need( + what="a model credential on the instance that runs it", + fix=f"set {MODEL_KEY} to an API key from any provider (DR-0004)", + met=lambda c: MODEL_KEY in c.configured, + ), + ), + limits=( + "It will not attempt anything in the red lane, and an error it cannot classify is red. " + "`hullwork projects lanes --checkout .` prints that policy against your own tree.", + "No fix without a test that fails first on untouched code (DR-0003). *I could not " + "reproduce this* is a result rather than a failure, and is the answer more often " + "than not.", + "One attempt per error, then a person.", + ), + ), +) + + +@dataclass(frozen=True) +class Answer: + """Whether one feature is available here, whether it is permitted, and what is in the way. + + **Three answers and not two** (DR-0019). *Available* is about what this project and instance + can do; *permitted* is about what the project has agreed to. Blending them would report a + decision somebody made as a part that is missing, which is the one way this report could + insult its reader. + """ + + feature: Feature + missing: tuple[Need, ...] + withheld: tuple[Need, ...] = () + + @property + def available(self) -> bool: + return not self.missing + + @property + def permitted(self) -> bool: + return not self.withheld + + +def examine(checkout: Checkout, features: Sequence[Feature] = FEATURES) -> list[Answer]: + """Which features this checkout can have, in the order they are declared. + + **Every need, not the first one that fails.** A reader who fixes one thing and runs this again + to find a second is a reader doing the work this command exists to save them. + """ + return [ + Answer( + feature, + tuple(need for need in feature.needs if not need.met(checkout)), + tuple(need for need in feature.permits if not need.met(checkout)), + ) + for feature in features + ] + + +def lines(answers: Sequence[Answer]) -> list[str]: + """The report, for a terminal. + + **The limits are printed whether or not the feature is available**, which is the whole of the + operator's framing: a feature you can have and a feature you cannot both have things they will + not do, and the first is the one where nobody thinks to look. + """ + said: list[str] = [] + for answer in answers: + if not answer.available: + mark = "no" + elif not answer.permitted: + # **A decision, spelled as one.** `no` here would read as a part that is missing, and + # somebody chose this. + mark = "not permitted here" + else: + mark = "yes" + said.append(f"[{mark}] {answer.feature.name} — {answer.feature.does}") + for need in answer.missing: + said.append(f" needs: {need.what}") + said.append(f" → {need.fix}") + for need in answer.withheld: + said.append(f" this project has not permitted it: {need.what}") + said.append(f" → {need.fix}") + for limit in answer.feature.limits: + said.append(f" limit: {limit}") + said.append("") + return said diff --git a/hullwork/forge/__init__.py b/hullwork/forge/__init__.py index 9bb56a2..6b70a06 100644 --- a/hullwork/forge/__init__.py +++ b/hullwork/forge/__init__.py @@ -627,9 +627,9 @@ def commit_files( head with an empty commit — so "the agent changed nothing" would otherwise become a branch and a pull request with no diff in it. - No sign-off trailer, ever, though the API adds one. CONTRIBUTING.md and the worker contract - both say the DCO sign-off is a human act performed at the merge gate; that a machine *can* - emit the trailer is exactly why it must not. + No sign-off trailer, ever, though the API adds one. CONTRIBUTING.md says the DCO + sign-off is a human act performed at the merge gate; that a machine *can* emit the + trailer is exactly why it must not. """ ... diff --git a/hullwork/forge/factory.py b/hullwork/forge/factory.py index 6b8eb51..55a0974 100644 --- a/hullwork/forge/factory.py +++ b/hullwork/forge/factory.py @@ -39,12 +39,16 @@ def make_forge(settings: Settings) -> Forge | None: def make_code_forge(settings: Settings) -> ForgeCode | None: - """The forge an agent pushes through. `None` until `HULLWORK_FORGE_CODE_TOKEN` is set. + """The forge Hullwork pushes verified work through. `None` until the code token is set. Separate function, separate setting, separate token — so that no request handler and no sweep can ever end up holding a credential that can write code. It falls back to nothing rather than to the ingest token: an accidental fallback is exactly how the boundary would be lost, quietly, on the day M2 lands. + + **Two callers now, and the second one runs no agent** (item 178). `hullwork deps --open` pushes + an upgrade the project's own suite passed, with no model and no gateway involved. This used to + say *the forge an agent pushes through*; `config.py` carries the decision and what it costs. """ if not settings.forge_url or not settings.forge_code_token: return None diff --git a/hullwork/forge/forgejo.py b/hullwork/forge/forgejo.py index 596a936..11d43b2 100644 --- a/hullwork/forge/forgejo.py +++ b/hullwork/forge/forgejo.py @@ -548,7 +548,7 @@ def commit_files( "author": identity, "committer": identity, # `signoff` is available and deliberately not set: the DCO sign-off is a human act - # performed at the merge gate (CONTRIBUTING.md, the worker contract). + # performed at the merge gate (CONTRIBUTING.md). "files": [_to_operation(change) for change in changes], } data = self._request("POST", f"/repos/{repo}/contents", json=payload) diff --git a/hullwork/manifest.py b/hullwork/manifest.py index fd9aae0..da6bd37 100644 --- a/hullwork/manifest.py +++ b/hullwork/manifest.py @@ -219,14 +219,37 @@ def _red_patterns_are_still_patterns(cls, value: list[str]) -> list[str]: class AutofixConfig(_Strict): - """How, and whether, an agent may attempt a fix. + """What Hullwork may do to this repository. **The permissions block**, and it is named after + one feature for historical reasons (DR-0019). `agent: none` is the default (DR-0002): the pipeline is fully useful with no external model call, and attempting fixes is what you opt into. + + **Every other block in this manifest describes what your project *is*** — the image, the test + command, the linter, where things live. This one describes what is *permitted*, and DR-0019 is + where that distinction was finally written down. The name stays `autofix` because a manifest + field is a public interface and every existing file would break for a better word. """ agent: str = NO_AGENT + #: Whether a verified-green dependency upgrade may be opened as a pull request here. DR-0019. + #: + #: **The first thing a project can refuse while Hullwork is perfectly able to do it**, and the + #: reason it exists is the gap item 186 could report and not close: until this field, declaring + #: an installer and a lock file *was* consenting to pull requests in your repository. Nobody + #: said so. That is the sentence this product already applies to lanes — *a policy nobody has + #: read is a policy nobody has agreed to* — pointed at itself. + #: + #: **False by default**, because every other default in this block is the refusing one and a + #: permission that arrives switched on is not a permission. + #: + #: DR-0019's rule for whether a feature gets a switch at all: *could a project have the + #: capability, understand the feature, and rationally not want it?* Applied to everything that + #: exists today it yields this field and nothing else — reading a lock file writes nothing, + #: verification runs on the operator's host, and both agent paths are already gated by `agent`. + open_upgrades: bool = False + @field_validator("agent") @classmethod def _an_engine_is_named_not_described(cls, value: str) -> str: diff --git a/hullwork/osv.py b/hullwork/osv.py new file mode 100644 index 0000000..34cb125 --- /dev/null +++ b/hullwork/osv.py @@ -0,0 +1,195 @@ +"""Asking OSV which of a project's pinned dependencies are known to be vulnerable. Item 172. + +**Why this database and not another**, recorded because DR-0016 had to check it before a line was +written: OSV is Apache-2.0, its API needs no key and no account, and it publishes no restriction on +commercial or hosted use. The same check removed CodeQL (free only on open-source code) and +Semgrep's own rule set (internal, non-competing, **non-SaaS**) from consideration on the same day — +either of those would have obliged this product's buyer to pay a competitor, or forbidden the +hosted edition DR-0015 plans. + +**It runs in the dispatcher and never in the sandbox.** The attempt has no network by design, and +nothing here is a reason to change that: the question *which versions are affected* is answered +from files on disk plus one host that is not the project's, before a container exists. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from dataclasses import dataclass + +import httpx2 + +from hullwork.dependencies import Dependency + +log = logging.getLogger(__name__) + +#: The public instance. Addressed rather than configurable: a vulnerability database an operator +#: could point somewhere else is a supply-chain decision wearing a settings field. +OSV_URL = "https://api.osv.dev" + +#: What one batch may carry. The service's own documented limit, and the reason this never sends +#: one request per package — a project with a thousand pins would otherwise be a thousand requests. +BATCH = 1000 + + +@dataclass(frozen=True) +class Advisory: + """One published vulnerability, and the versions that end it. + + `fixed` is a tuple rather than a single value **on purpose**. An advisory that fixed a problem + on two release branches publishes two, and picking one would mean comparing versions across two + ecosystems' ordering rules — a wrong pick there is a bump that does not fix what it claims to. + All of them are reported and a person decides. + """ + + id: str + summary: str + fixed: tuple[str, ...] + + @property + def has_a_fix(self) -> bool: + """Whether there is any bump to attempt at all. + + Empty means the advisory publishes no fixed version — not that this failed to find one. + Proposing the next release and hoping is the guess this project does not make. + """ + return bool(self.fixed) + + @property + def url(self) -> str: + """Where a person reads it themselves, which is the point of naming the id.""" + return f"https://osv.dev/vulnerability/{self.id}" + + +@dataclass(frozen=True) +class Finding: + """A pinned dependency, and everything published against that exact version.""" + + dependency: Dependency + advisories: tuple[Advisory, ...] + + +class Osv: + """The vulnerability database, seen only as "which of these versions are affected?". + + Narrow on purpose, the way `TrackerInventory` is: an object that can only be asked one question + cannot accidentally be asked another. + """ + + def __init__(self, *, timeout: float = 20.0, transport: object | None = None) -> None: + self._client = httpx2.Client( + base_url=OSV_URL, + headers={"Accept": "application/json"}, + timeout=timeout, + follow_redirects=False, + **({"transport": transport} if transport is not None else {}), # type: ignore[arg-type] + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> Osv: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def affected(self, deps: Sequence[Dependency]) -> list[Finding]: + """Every dependency with something published against its pinned version. + + Two round trips at most per batch: `querybatch` answers with ids only, so the detail — + which is where the fixing version lives — is fetched once per **id**, not once per package. + A clean project therefore costs exactly one request. + """ + findings: list[Finding] = [] + for start in range(0, len(deps), BATCH): + window = list(deps[start : start + BATCH]) + findings.extend(self._one_batch(window)) + return findings + + def _one_batch(self, window: Sequence[Dependency]) -> list[Finding]: + queries = [ + {"package": {"name": d.name, "ecosystem": d.ecosystem}, "version": d.version} + for d in window + ] + answered = self._post("/v1/querybatch", {"queries": queries}) + results = answered.get("results") if isinstance(answered, dict) else None + if not isinstance(results, list): + return [] + + # One fetch per distinct id: the same advisory routinely affects several packages, and + # asking for it once per package would multiply the requests by nothing gained. + detail: dict[str, dict[str, object]] = {} + findings: list[Finding] = [] + for dependency, result in zip(window, results, strict=False): + ids = _ids_in(result) + if not ids: + continue + advisories = [] + for vuln_id in ids: + if vuln_id not in detail: + detail[vuln_id] = self._get(f"/v1/vulns/{vuln_id}") + advisories.append(_advisory_for(dependency, vuln_id, detail[vuln_id])) + findings.append(Finding(dependency, tuple(advisories))) + return findings + + def _post(self, path: str, payload: dict[str, object]) -> dict[str, object]: + response = self._client.post(path, json=payload) + response.raise_for_status() + body = response.json() + return body if isinstance(body, dict) else {} + + def _get(self, path: str) -> dict[str, object]: + response = self._client.get(path) + response.raise_for_status() + body = response.json() + return body if isinstance(body, dict) else {} + + +def _ids_in(result: object) -> list[str]: + """The vulnerability ids in one `querybatch` result, which is `{}` when there are none.""" + if not isinstance(result, dict): + return [] + vulns = result.get("vulns") + if not isinstance(vulns, list): + return [] + return [v["id"] for v in vulns if isinstance(v, dict) and isinstance(v.get("id"), str)] + + +def _advisory_for( + dependency: Dependency, vuln_id: str, document: dict[str, object] +) -> Advisory: + """The fixing versions **for this package**, out of an advisory that may name several. + + One advisory routinely covers the same flaw across ecosystems. Reading every `fixed` event in + the document would hand a PyPI dependency npm's fixing version — a wrong answer that reads as + entirely plausible in a report, which makes it worse than an obvious one. + """ + fixed: list[str] = [] + affected = document.get("affected") + for entry in affected if isinstance(affected, list) else []: + if not isinstance(entry, dict): + continue + package = entry.get("package") + if not isinstance(package, dict): + continue + same = package.get("name") == dependency.name + if not same or package.get("ecosystem") != dependency.ecosystem: + continue + ranges = entry.get("ranges") + for one in ranges if isinstance(ranges, list) else []: + if not isinstance(one, dict): + continue + events = one.get("events") + for event in events if isinstance(events, list) else []: + if isinstance(event, dict) and isinstance(event.get("fixed"), str): + fixed.append(str(event["fixed"])) + + summary = document.get("summary") + return Advisory( + id=vuln_id, + summary=summary if isinstance(summary, str) else "", + # De-duplicated, order preserved: two ranges can name the same fixing version. + fixed=tuple(dict.fromkeys(fixed)), + ) diff --git a/hullwork/outcomes.py b/hullwork/outcomes.py index 4c92bd5..5ded31e 100644 --- a/hullwork/outcomes.py +++ b/hullwork/outcomes.py @@ -322,3 +322,174 @@ def review_lines(counted: Reviewed) -> list[str]: median = ordered[len(ordered) // 2] out.append(f"median time from first error to decision: {spoken(median)}") return out + + +#: Attempt outcomes that put a **verdict the gates produced** behind an item. Item 183, DR-0017. +#: +#: The two refusals belong here and that is the decision's second consequence, not a rounding: *"I +#: could not verify this" is a first-class result*, and a reasoned refusal with the runs attached is +#: what this product promises to deliver where nobody else does. What is excluded is everything that +#: says nothing about the claim — `abandoned` because the infrastructure got in the way, +#: `baseline-red` because the project's own suite stopped it before a model was called, +#: `already-fixed` because it is a fact about a deployment. +LEFT_WITH_EVIDENCE: frozenset[AttemptOutcome] = frozenset( + { + AttemptOutcome.PR_OPEN, + AttemptOutcome.PR_OPEN_LINT_FAILED, + AttemptOutcome.FAILED, + AttemptOutcome.NOT_REPRODUCIBLE, + } +) + +#: The two of those that carry a change somebody can merge. The rest are refusals, and the split is +#: printed rather than totalled — see `desk_lines`. +CARRIED_A_CHANGE: frozenset[AttemptOutcome] = frozenset( + {AttemptOutcome.PR_OPEN, AttemptOutcome.PR_OPEN_LINT_FAILED} +) + +#: States that mean Hullwork put this on somebody's desk rather than taking it off. Item 183. +#: +#: **The row `Funnel` cannot have**, and the reason this count exists. DR-0017's Context says the +#: first half of the pipeline is a *cost* to the buyer — *"a team of three to ten with Sentry has no +#: detection problem; it has more issues than it can serve. The opening move of the product adds to +#: the pile."* An item nobody may attempt is exactly that, and a number that cannot express it is a +#: number that flatters. +HANDED_OVER: frozenset[ItemState] = frozenset({ItemState.HUMAN_ONLY, ItemState.REJECTED}) + + +@dataclass +class Desk: + """How much of what arrived left a person's desk with evidence attached. Item 183, DR-0017. + + **The denominator is the whole point.** `Funnel`'s is `fair_try` — attempts that spent an item's + one try — so every question it can answer has the shape *of the attempts we made, how did they + go*. That is the "how many bugs did it fix" number in a more careful coat, and DR-0017's third + consequence names it as the one being replaced. This one counts **what arrived**, which is a + different question with a much worse available answer. + """ + + #: Claims that arrived. Every item is one, whatever became of it. + arrived: int = 0 + #: Items with a verdict the gates produced behind them. + left_with_evidence: int = 0 + #: …of those, the ones carrying a change. The remainder are refusals, and both are printed. + with_a_change: int = 0 + with_a_refusal: int = 0 + #: Still in the queue, including anything whose attempt was abandoned and went back. + still_waiting: int = 0 + #: **Put on** a person's desk: red lane, or a pull request a human read and refused. + handed_over: int = 0 + #: Attempted and not finished. Neither, and saying so costs one word (`Funnel`'s rule). + running: int = 0 + + def as_dict(self) -> dict[str, object]: + """For `--json`. No ratio and no percentage, for `Funnel.as_dict`'s reason: an operator who + wants one computes it from parts they can see, and six samples do not carry that precision. + """ + return { + "arrived": self.arrived, + "left_with_evidence": self.left_with_evidence, + "with_a_change": self.with_a_change, + "with_a_refusal": self.with_a_refusal, + "still_waiting": self.still_waiting, + "handed_over": self.handed_over, + "running": self.running, + } + + +def desk(session: Session) -> Desk: + """Count what became of every claim that arrived. Item 183. + + **By the attempt trail and never by the item's state**, which is the one thing here that could + be got wrong quietly. `done` is reached both by a merged pull request and by a person fixing + their own bug and closing the issue, and there is no state history to separate them — so an item + counted by state would have this product claiming credit for somebody else's afternoon. + + One pass over two tables rather than a query per bucket: this runs inside `status`, which an + operator types when something is already wrong. + """ + verdicts = session.execute( + select(Attempt.item_id, Attempt.outcome, Attempt.rehearsal).where( + Attempt.outcome.is_not(None) + ) + ).all() + + settled: dict[int, AttemptOutcome] = {} + started: set[int] = set() + for item_id, outcome, rehearsal in verdicts: + if rehearsal: + # It publishes nothing, so no forge state and nobody's queue moved. `Funnel` keeps + # rehearsals out of every number for the same reason. + continue + started.add(item_id) + # The best verdict an item ever got, so a second attempt that abandoned cannot take an + # earned one away. Items get one attempt (DR-0003), and this is what makes that assumption + # visible rather than relied on. + if outcome in LEFT_WITH_EVIDENCE and settled.get(item_id) not in LEFT_WITH_EVIDENCE: + settled[item_id] = outcome + + running = { + item_id + for (item_id,) in session.execute( + select(Attempt.item_id).where( + Attempt.outcome.is_(None), Attempt.rehearsal.is_(False) + ) + ).all() + } - started + + counted = Desk() + for item_id, state in session.execute(select(Item.id, Item.state)).all(): + counted.arrived += 1 + outcome = settled.get(item_id) + if outcome is not None: + counted.left_with_evidence += 1 + if outcome in CARRIED_A_CHANGE: + counted.with_a_change += 1 + else: + counted.with_a_refusal += 1 + elif item_id in running: + counted.running += 1 + elif state in HANDED_OVER: + counted.handed_over += 1 + else: + counted.still_waiting += 1 + return counted + + +def desk_lines(counted: Desk) -> list[str]: + """The number DR-0017 signed for, in words. Empty when nothing has arrived. + + **Nothing here is phrased as an achievement**, and the row for what was *added* to a desk least + of all: it is the one line in this product that can embarrass it, and rounding it into good news + is exactly how it would stop doing that. + + Zeros are not printed. An instance whose claims have all cleared says so with one line, and an + instance that has cleared none says *that* — which is not the same fact as a row of noughts, and + is the state every instance starts in. + """ + if not counted.arrived: + return [] + + said = [f"{counted.arrived} claim(s) have arrived"] + if counted.left_with_evidence: + how = [] + if counted.with_a_change: + how.append(f"{counted.with_a_change} with a change") + if counted.with_a_refusal: + how.append(f"{counted.with_a_refusal} with a reasoned refusal and the runs behind it") + said.append( + f"{counted.left_with_evidence} left your desk with evidence attached: " + ", ".join(how) + ) + else: + said.append("none of them has left your desk with evidence attached yet") + if counted.still_waiting: + said.append(f"{counted.still_waiting} are still in the queue") + if counted.running: + said.append(f"{counted.running} are being attempted now, which is neither") + if counted.handed_over: + # Said plainly, and this is the sentence DR-0017's Context is about. + said.append( + f"{counted.handed_over} went onto your desk rather than off it: red lane, or a pull " + f"request somebody read and refused" + ) + return said diff --git a/hullwork/page.py b/hullwork/page.py index 6f0790b..e644460 100644 --- a/hullwork/page.py +++ b/hullwork/page.py @@ -1154,9 +1154,9 @@ def instance( """What `hullwork status` says, for somebody who does not have a terminal on this host. **Every number comes from the function `status` calls**, never from a second query written for - this page: `readiness.check`, `outcomes.funnel`, `recurrence.counted` and `undecided`, - `lease.state` and `reporting_of`. A page that recomputed them would drift, and the first anybody - would know is a reader and an operator disagreeing about the same instance. + this page: `readiness.check`, `outcomes.desk`, `outcomes.funnel`, `recurrence.counted` and + `undecided`, `lease.state` and `reporting_of`. A page that recomputed them would drift, and the + first anybody would know is a reader and an operator disagreeing about the same instance. """ from hullwork import lease, outcomes, readiness, recurrence @@ -1195,6 +1195,11 @@ def instance( ), ] table = "".join(f"{_h(name)}{_h(value)}" for name, value in rows) + # **The number DR-0017 is measured by** (item 183), and it was in the terminal and not here — + # which is the same defect item 136 already found on this page once: a fact the instance knew, + # put where nobody reading would find it. The interface design says this surface exists + # to show what was verified and what was not; a count of attempts is not that, and this is. + desk = "".join(f"
  • {_h(line)}
  • " for line in outcomes.desk_lines(outcomes.desk(session))) attempts = "".join(f"
  • {_h(line)}
  • " for line in outcomes.lines(outcomes.funnel(session))) spent = "".join( f"
  • {_h(line.strip())}
  • " @@ -1245,6 +1250,10 @@ def instance( "How this instance is configured", f'
    {table}
    ', ) + # Before the attempts block, exactly as `status` orders them: this one has *what arrived* + # as its denominator and that one has *what was attempted*, so a reader who opens one + # should meet the wider question first. + + (_fold("What arrived, and how much left your desk", f"") if desk else "") + (_fold("What its attempts came to", f"") if attempts else "") + (_fold("What they cost", f"") if spent else "") + (_fold("What reviewers did", f"") if reviewed else "") diff --git a/hullwork/propose.py b/hullwork/propose.py index 1c60d7a..85bf1a0 100644 --- a/hullwork/propose.py +++ b/hullwork/propose.py @@ -45,7 +45,7 @@ import re from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any +from typing import Any, NamedTuple import yaml @@ -61,6 +61,83 @@ ".gitlab-ci.yml", ) +#: CI locations that name the forge holding the repository, and the ones that do not. +#: +#: **`.github/workflows/` is deliberately absent, and that absence is the whole subject of +#: item 171.** Forgejo Actions and Gitea Actions both read that directory — this repository's +#: own deployment runs those workflows on a Forgejo instance — so treating it as evidence of +#: GitHub would be wrong for exactly the self-hosted projects this product is for. +CI_NAMES_THE_FORGE: tuple[tuple[str, str], ...] = ( + (".forgejo/workflows/", "forgejo"), + (".gitea/workflows/", "gitea"), + (".gitlab-ci.yml", "gitlab"), +) + +#: Hosts that name themselves. Everything else is self-hosted and unresolvable from here: +#: `git.example.com` may be Forgejo, Gitea, a private GitHub or a self-hosted GitLab, and no +#: request may be made to find out — `propose` reaches nothing and needs no credential. +HOSTS_THAT_NAME_THE_FORGE: dict[str, str] = { + "github.com": "github", + "gitlab.com": "gitlab", +} + +#: What `git.provider` says when nothing decided. +#: +#: A value rather than a placeholder, unlike `_coordinate_of`'s `owner/name`. The field is +#: required, so an unparseable proposal would cost every reader a fix to serve the undecidable +#: minority — and unlike a repository coordinate, a wrong forge name is something an operator +#: recognises on sight. What it must not do is look like a reading, so `render` says it is a +#: default and names what would have settled it. +PROVIDER_WHEN_UNDECIDED = "forgejo" + + +class ForgeGuess(NamedTuple): + """Which forge holds a repository, and what said so. + + `evidence` is `None` when nothing did. That is not a detail for the caller to ignore: the + difference between an observation and a default is what `render`'s contract is about. + """ + + provider: str + evidence: str | None + + +def host_of_remote(url: str | None) -> str | None: + """The host out of a git remote URL, in either spelling, or `None`. + + `_coordinate_of` has parsed this URL since item 107 and kept only the last two segments — + discarding the one part of it that says which forge this is (item 171). + """ + if not url: + return None + trimmed = url.strip().removesuffix(".git") + for scheme in ("https://", "http://", "ssh://", "git://"): + trimmed = trimmed.removeprefix(scheme) + # `git@host:owner/name` and `git@host/owner/name` after the scheme is gone. + _, _, after_user = trimmed.rpartition("@") + host = re.split(r"[:/]", after_user, maxsplit=1)[0] + # A host has a dot and no whitespace. Anything else was not a URL, and guessing from it + # would be the constant-in-a-costume this function exists to remove. + if not host or " " in host or "." not in host: + return None + return host.lower() + + +def forge_for(source: str | None, remote_host: str | None) -> ForgeGuess: + """Which forge holds this repository. Pure, and reaches nothing. + + **The host outranks the CI location**, because where a repository lives beats which runner + reads its workflows: a GitHub repository whose workflows sit in `.forgejo/workflows/` is a + mirror, and the coordinate a manifest needs is the one that answers requests. + """ + named = HOSTS_THAT_NAME_THE_FORGE.get(remote_host or "") + if named: + return ForgeGuess(named, f"the origin remote is on {remote_host}") + for prefix, provider in CI_NAMES_THE_FORGE: + if source and source.startswith(prefix): + return ForgeGuess(provider, f"the CI configuration is at {prefix}") + return ForgeGuess(PROVIDER_WHEN_UNDECIDED, None) + #: Package-manager invocations that mean "this step installs the dependencies". #: #: About a dozen, stable for years, and **being wrong here is free**: an unrecognised @@ -200,6 +277,9 @@ class Proposal: repo: str #: The CI file it read, or `None` when there was none. source: str | None = None + #: The host of the `origin` remote, when this was read from a checkout that has one. + #: Item 171 — the strongest signal for `git.provider`, and it was being thrown away. + remote_host: str | None = None base: str | None = None install: str | None = None tests: str | None = None @@ -904,6 +984,25 @@ def only_files_that_exist(proposal: Proposal, paths: object) -> Proposal: return proposal +def _the_git_lines(proposal: Proposal) -> list[str]: + """`git:`, and whether its provider was read or defaulted to. Item 171. + + Uncommented means observed, everywhere else in this output. A constant printed under that + rule is the failure this file exists to avoid, so an undecided provider is preceded by a + comment saying so rather than being quietly indistinguishable from a reading. + """ + guess = forge_for(proposal.source, proposal.remote_host) + line = f"git: {{provider: {guess.provider}, repo: {proposal.repo}}}" + if guess.evidence: + return [line] + return [ + "# `provider` below is a default, not a reading: neither the origin remote's host", + "# nor the CI path named a forge. `.github/workflows/` cannot name one — Forgejo", + "# and Gitea Actions read that directory too. Correct it if this is not a Forgejo.", + line, + ] + + def render(proposal: Proposal) -> str: """The proposal as manifest text: observed values live, everything else commented. @@ -920,7 +1019,7 @@ def render(proposal: Proposal) -> str: "# that looks finished is a proposal nobody checks.", "", f"project: {name}", - f"git: {{provider: forgejo, repo: {proposal.repo}}}", + *_the_git_lines(proposal), "", ] @@ -970,6 +1069,36 @@ def render(proposal: Proposal) -> str: "# install from — it is the cache key — and this reader could not name one", "# for that command. Add the file your project reads and uncomment both.", ] + elif proposal.base: + # **What no installer costs, said where the field is not** (item 185). Every other field + # this reader cannot fill carries a comment explaining what is missing; `install` carried + # none, because its absence produces a manifest that **parses and builds perfectly**. What + # it cannot do is measure a dependency upgrade: with no installer the image is the base + # exactly as it comes, so rewriting a pin changes nothing the suite runs against. + # + # Measured on 2026-08-09 (item 182) before this comment existed: a checkout pinning + # `jinja2==2.4.1`, a base image carrying 3.0.0, and a verdict reading *your suite passed + # before this change and passes after it* — about a version that was never installed. + # + # The command is deliberately not named: it is not in the published image, and naming + # something a reader cannot run invites them to type it and be told it does not exist. + lines += [ + f"{mark}# No `install:` — nothing in the CI file named one, and this reader does not", + f"{mark}# guess between pip, uv and poetry from a lock file. Your tests will run: the", + f"{mark}# image is `{proposal.base}` exactly as it comes.", + f"{mark}#", + f"{mark}# What it costs: dependency upgrades cannot be **measured** against this", + f"{mark}# manifest. Nothing is installed from a lock file, so changing a pinned", + f"{mark}# version changes nothing your suite would run against, and a green suite", + f"{mark}# would say nothing about the upgrade.", + f"{mark}#", + f"{mark}# To answer that, keep this base and add two lines — the command your CI", + f"{mark}# already uses, and the file it reads:", + f"{mark}# install: ", + f"{mark}# dependencies: []", + f"{mark}# That is one layer on top of the image you named, not a rebuild from", + f"{mark}# scratch, and it is why no list here has to grow for your ecosystem.", + ] if proposal.packages: lines.append(f"{mark}packages: [{', '.join(proposal.packages)}]") if proposal.dependencies: diff --git a/hullwork/refit.py b/hullwork/refit.py new file mode 100644 index 0000000..8fd0850 --- /dev/null +++ b/hullwork/refit.py @@ -0,0 +1,406 @@ +"""The fix for the ones that break. Item 179, DR-0018 step 4. + +`deps --verify` ends with a queue, and the middle of it is the interesting part: *six break, tests +named*. Renovate leaves those where they fell; DR-0018 says this is the one item on its list nobody +else could ship. Making a broken upgrade fit is a refactor, it is what everybody postpones, and the +loop this repository already has applies to it without modification. + +**Cheaper than it looks, because the expensive half is already paid for.** DR-0003's cost is *write +a test that reproduces the problem and show it failing first*, and item 174 produces exactly that as +a by-product of the verdict: the project's own tests, failing against the upgraded dependency, with +nobody having authored them for the occasion. No agent writes the oracle here, which is the property +`docs/what-hullwork-is.md` says every verdict rests on. + +**What this module is and is not.** The sequence and its gates belong to `dispatch.refit`; the world +those run in belongs to `work._attempt`. What is here is the part neither of them should know: what +a breakage *is*, what the agent is told about it, and how the version is read back out of the tree +afterwards. Nothing here starts a container or touches a forge. +""" + +from __future__ import annotations + +import logging +import re +import shutil +import tempfile +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from sqlalchemy.orm import Session + +from hullwork import bump, dependencies, resolve +from hullwork.manifest import Manifest +from hullwork.models import Item, ItemKind, ItemState, Lane, Project +from hullwork.normalise import derive_fingerprint +from hullwork.states import transition + +if TYPE_CHECKING: # `work` imports this module inside `_attempt`, so the runtime import is lazy + from hullwork.config import Settings + from hullwork.work import Outcome + +log = logging.getLogger(__name__) + + +def _canonical(name: str) -> str: + """PEP 503 again, and for the third time in this repository deliberately rather than shared. + + `bump` and `resolve` each carry one because each is about a different file's spellings. This one + compares what OSV named against what a lock reader read back, and the two disagree the same way: + `Jinja2`, `jinja_2` and `jinja.2` are one distribution. npm names pass through unchanged, since + they contain none of the characters this collapses. + """ + return re.sub(r"[-_.]+", "-", name).lower() + + +@dataclass(frozen=True) +class Upgrade: + """One upgrade that broke a suite, and everything a refit needs to know about it. + + Frozen, and built once from the verdict rather than re-derived: the version this is about is + the one the gates ran against, and a second derivation is a second chance to disagree with it. + """ + + package: str + #: What the project pins today. Carried for the report, not for the run — nothing here ever + #: puts it back, which is the whole point of the item. + was: str + #: What the upgrade moves to, and what the tree must still pin when the green gate passes. + to: str + #: The dependency file that pins it, as a path relative to the checkout. + source: str + #: Every file moving this dependency can rewrite — read-only to the fix phase. Item 175 measured + #: that `npm install` rewrites `package.json` as well as the lock, so guarding only the file + #: that pins would leave the range widened back with the pin looking untouched. + guarded: tuple[str, ...] + #: The tests that failed with the upgrade applied, in the runner's own words. This is the + #: evidence, and it was written by the project rather than for the occasion. + failing: str = "" + #: The advisory that started this, when there is one. Empty is legal: an upgrade can be worth + #: making fit without anything published against the version it replaces. + advisory: str = "" + url: str = "" + + @property + def title(self) -> str: + """What a person reads in a queue. The pair of versions, because that is the work.""" + return f"{self.package} {self.was} → {self.to} breaks this project's suite" + + @property + def fingerprint(self) -> str: + """The identity of this work: the package **and both versions**, never the package alone. + + A fingerprint over the name would make next month's upgrade of the same library a repeat of + this one — `dedup` would increment a counter and no work would be created, which is the + failure mode that is invisible because it looks like deduplication working. + """ + return derive_fingerprint("deps", self.package, self.was, self.to, self.source) + + +def from_report( + report: bump.Report, + *, + source: str, + guarded: tuple[str, ...] = (), + advisory: str = "", + url: str = "", +) -> Upgrade | None: + """The upgrade worth handing to an agent, or `None` when this report is not that. + + **Only `needs work` reaches here.** A clean verdict is item 178's to deliver and needs no + agent; a red baseline is the project's own problem and nothing can be claimed against it; a + blocked one has nothing to try. Filtering by `needs_of` rather than by scanning for a `breaks` + answer is what keeps those three out — a report can carry a `breaks` answer *and* a later clean + one, and that is a package to take rather than work to do. + + Among the candidates that broke, the one that broke **fewest** tests. Same reasoning as + `bump.broke`'s ordering: the upgrade that breaks two tests is the one that can be closed this + afternoon, and starting with the twelve-test one buries the achievable under the daunting. + """ + if bump.needs_of(report) is not bump.Needs.NEEDS_WORK: + return None + broke = [a for a in report.answers if a.verdict is bump.Verdict.BREAKS] + if not broke: # pragma: no cover - `needs_of` returns NEEDS_WORK only when one exists + return None + chosen = min(broke, key=lambda a: (_failure_count(a.detail), a.to)) + return Upgrade( + package=report.package, + was=report.was, + to=chosen.to, + source=source, + guarded=guarded or (source,), + failing=chosen.detail, + advisory=advisory, + url=url, + ) + + +def _failure_count(detail: str) -> int: + """How many tests a `breaks` answer named. Blank lines are not failures.""" + return len([line for line in detail.splitlines() if line.strip()]) + + +def guarded_for(source: str) -> tuple[str, ...]: + """Every file that moving this dependency can rewrite, asked of the resolver that owns it. + + Read from `resolve.touches` rather than listed here, so an ecosystem added there is guarded here + without anybody remembering to. A file with no resolver is a list of versions and is the only + file its own move touches. + """ + resolver = resolve.resolver_for(source) + return resolve.touches(resolver) if resolver is not None else (source,) + + +def version_now(upgrade: Upgrade, worktree: Path) -> str | None: + """What this tree pins the package at **now**, read back after the gates have run. + + Item 172's readers rather than a fifth parser: they already know all four file shapes, they are + the ones `deps` used to find this dependency in the first place, and a second reader is a second + thing that can come to disagree about what a file says. + + `None` for a tree that no longer pins it at all — a deleted file, a removed line — which is a + different fact from pinning the old version and is reported as one. + """ + + def read(path: str) -> str | None: + try: + return (worktree / path).read_text(encoding="utf-8", errors="replace") + except OSError: + return None + + wanted = _canonical(upgrade.package) + for found in dependencies.read_lockfiles([upgrade.source], read): + if _canonical(found.name) == wanted: + return found.version + return None + + +#: How much of the runner's output goes into the brief. Enough for the failures and their messages, +#: bounded because a suite that fails 300 tests must not become most of the prompt. +MAX_FAILING_CHARS = 3_000 + + +def brief(upgrade: Upgrade) -> str: + """What the agent is told. **Not `brief.build`**, and the difference is the honesty of it. + + That one answers *what Hullwork knows about this error* from the tracker and this instance's + history, and for a refit every one of those fields is empty — there is no error, no fingerprint + from a stranger, no occurrence count. A brief built from it would open by saying the full event + was never fetched, which is true of a tracker nobody asked and misleading about work that has + better evidence than any tracker produces. + + Nothing here is untrusted in the sense `brief.build` fences against: the package name and the + versions came from a lock file and a vulnerability database, and the failing tests came from the + project's own runner. The runner's output is still bounded, because a suite can print for as + long as you let it. + """ + failing = upgrade.failing.strip()[:MAX_FAILING_CHARS] or "(the runner named none)" + lines = [ + "# What Hullwork knows about this upgrade", + "", + "This is context you cannot get by reading the repository: the upgrade below has already " + "been applied to the checkout you are working in, and these are the tests it broke when " + "Hullwork ran your own suite against it.", + "", + "## The upgrade", + "", + f"- Package: `{upgrade.package}`", + f"- Pinned at: `{upgrade.was}`", + f"- Applied here: `{upgrade.to}`", + f"- Pinned by: `{upgrade.source}`", + ] + if upgrade.advisory: + lines.append(f"- Advisory: {upgrade.advisory}{f' — {upgrade.url}' if upgrade.url else ''}") + lines += [ + "", + "## What it broke", + "", + "Your own tests, run by Hullwork with the upgrade applied and nothing else changed:", + "", + "```text", + failing, + "```", + "", + "## What you are being asked for", + "", + "Change this project's own source code so those tests pass with the new version. The whole " + "suite has to pass, not only the ones named above.", + "", + "## What you must not do", + "", + f"**The dependency files are read-only.** {', '.join(upgrade.guarded)} — do not edit, " + f"delete or replace any of them.", + "", + f"Putting `{upgrade.package}` back to `{upgrade.was}` would make the suite pass and is not " + f"a fix: it is a revert, and it undoes the upgrade this work exists to make possible. " + f"Hullwork restores those files before it runs the suite again and reads the version back " + f"out of the tree afterwards, so a revert is reported as a revert rather than published as " + f"a fix.", + "", + "If the upgrade genuinely cannot be made to work, change nothing and say so. That is a " + "correct and useful answer, and it is a better one than a change that only looks like a " + "fix.", + "", + ] + return "\n".join(lines) + + +def prepare( + checkout: Path, upgrade: Upgrade, *, present: Sequence[str], into: Path +) -> str | None: + """A copy of the checkout with the upgrade already in it, or the reason there is not one. + + **The upgrade goes in before the attempt starts, not during it**, and that is what makes the + first gate a red gate rather than a baseline. The agent then opens a tree where the new version + is simply what the project pins, which is also the tree a reviewer will see. + + Applied by the same two paths `bump` uses and not by a third: the ecosystem's own resolver for a + resolved graph (`resolve.upgrade`, which does not believe the tool's exit code either), and + `bump.editing` for a file that is a list of versions. A refit that moved a dependency its own + way would be a second opinion about what an upgrade is. + + **`.git` is not copied.** The agent gets history only if somebody chose to give it, and a + worktree with a repository in it can grow a hook that runs on the host — `prepare_worktree`'s + reasoning, and this directory is handed to the same machinery. + """ + shutil.copytree( + checkout, into, dirs_exist_ok=True, ignore=shutil.ignore_patterns(".git") + ) + resolver = resolve.resolver_for(upgrade.source) + if resolver is None: + try: + bump.can_rewrite(upgrade.source) + except bump.CannotRewriteError as refused: + return str(refused) + return bump.editing(upgrade.source, upgrade.package, upgrade.to)(into) + outcome = resolve.upgrade( + resolver=resolver, + worktree=into, + package=upgrade.package, + version=upgrade.to, + present=present, + run=resolve.in_a_container, + ) + return None if outcome.ok else f"{outcome.outcome.value}: {outcome.detail}" + + +def stage( + session: Session, manifest: Manifest, upgrade: Upgrade, *, repo: str +) -> tuple[Project, Item]: + """Put one project and one item where the dispatcher reads them from. Item 179. + + Shaped after `trial.stage`, and it differs in one place on purpose: that one goes through + `dedup.resolve` because a pasted stack trace has to be triaged, and the lane it lands in is the + product working. **There is nothing to triage here.** Triage matches lane rules against an + error's title and the code location that raised it, and this work has neither — the identity is + a package and two versions, both known exactly, and the code that will be changed is not known + until the agent has read the failures. + + So the lane is stated rather than derived, with the reason on the item where a person can + disagree with it. Green, because what an agent is being asked to touch is the project's own + source until its own suite passes again — the same territory `autofix` already covers — and + because the operator asked for this upgrade by name. + """ + project = session.query(Project).filter(Project.repo == repo).one_or_none() + if project is None: + project = Project( + slug=repo.rsplit("/", 1)[-1], + forge=manifest.git.provider, + repo=repo, + webhook_secret_hash="", # nothing listens: a refit has no webhook to authenticate + manifest=manifest.model_dump(mode="json"), + ) + session.add(project) + session.flush() + + item = Item( + project_id=project.id, + fingerprint=upgrade.fingerprint, + title=upgrade.title, + kind=ItemKind.OTHER, + lane=Lane.GREEN, + lane_reason=( + "a dependency upgrade the operator named, whose failing tests are the project's own — " + "there is no error to triage and no culprit to match a lane rule against, so the lane " + "is stated here rather than derived" + ), + permalink=upgrade.url or None, + ) + session.add(item) + session.flush() + # Through the state machine and never by assignment, which is item 042's single door. `new` is + # where a row starts and `triaged` is what it has to pass through, even when — as here — the + # triage was a decision rather than a match. + transition(item, ItemState.TRIAGED) + transition(item, ItemState.READY) + session.flush() + return project, item + + +class NotUpgradableError(Exception): + """The upgrade could not be put into the tree, so no attempt was started. Item 179. + + Its own exception because nothing failed: a manifest whose range forbids the version, a lock + file with no resolver, a registry that refused — each is a fact about the project, and the + attempt was never begun, so nothing was consumed and nothing is owed. `resolve.upgrade`'s own + refusals arrive here word for word rather than being summarised into "could not upgrade". + """ + + +def run( + settings: Settings, + checkout: Path, + manifest: Manifest, + upgrade: Upgrade, + *, + present: Sequence[str], + into: Path, + repo: str, +) -> Outcome: + """One refit, end to end. Composes what exists; decides nothing new. + + Shaped after `trial.run` and for the same reason: an ephemeral database, no forge anywhere in + the call path, and the artefact written to disk through `write_locally`. The forges are `None` + and `_attempt` is handed a checkout, so nothing here can reach one — which is stronger than not + configuring one, and it keeps this half of DR-0018 on the credential-free side of item 178. + + **What it does not remove is Docker and a model credential**, and saying so is part of the + honesty. The claim is that a project's own suite failed with an upgrade applied and passes with + a change, run in a sandbox, by a model whose identity was read off the wire. Faking either turns + this into a demonstration of itself. + """ + from hullwork import trial, work + from hullwork.scrub import instance_secrets + + session = trial.ephemeral_session() + project, item = stage(session, manifest, upgrade, repo=repo) + + # The sha of the tree the upgrade goes on top of, read **before** the copy: `prepare` leaves + # `.git` behind, so afterwards there is nothing to ask. Everything the artefact claims is a + # claim about one commit, and one that said `unknown` would be an artefact nobody could check. + base = trial.head_sha(checkout) + upgraded = Path(tempfile.mkdtemp(prefix="hullwork-refit-")) + try: + refused = prepare(checkout, upgrade, present=present, into=upgraded) + if refused is not None: + raise NotUpgradableError(refused) + log.info( + "refit starting", + extra={"package": upgrade.package, "to": upgrade.to, "sha": base}, + ) + return work._attempt( + session, + settings, + work.Eligible(item=item, project=project), + code_forge=None, + forge=None, + credential=work._model_credential(settings), + secrets=instance_secrets(settings), + rehearse_into=into, + local_checkout=work.Checkout(path=upgraded, sha=base), + upgrade=upgrade, + ) + finally: + shutil.rmtree(upgraded, ignore_errors=True) + session.close() diff --git a/hullwork/resolve.py b/hullwork/resolve.py new file mode 100644 index 0000000..440cf1c --- /dev/null +++ b/hullwork/resolve.py @@ -0,0 +1,280 @@ +"""Moving a resolved dependency graph, by running the tool that knows how. Item 175, DR-0016. + +**Why this is not a file edit.** A lock file is a *resolved graph*: moving `jinja2` can require +moving `markupsafe`, and moving that can require moving something else. Editing one version string +leaves a file that is internally incoherent — and the bad outcome is not that it fails to install, +it is that it **installs**, after which a green suite means nothing at all. That is a false verdict +of the exact kind DR-0017 says this product exists to prevent, produced by the product itself. + +Only the ecosystem's own resolver knows how to move that graph. So nothing here parses a lock file. +It runs `npm`, or `uv`, or `poetry`, in a container, and reads back what they wrote. + +**And it does not believe them either.** A manifest can forbid the upgrade — `"lodash": "^4.17.11"` +does not permit 5.x — and every one of these tools reports success after resolving to the highest +version the range allows, having not applied the fix. So the lock is re-read and the version +checked. The tool's exit code is not the verdict, which is the same rule the gates run on. +""" + +from __future__ import annotations + +import json +import logging +import tomllib +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +log = logging.getLogger(__name__) + +#: How long a resolver may take. Generous: it is a registry round trip plus a graph solve, and a +#: cold npm cache on a large tree is genuinely slow. +RESOLVE_TIMEOUT_SECONDS = 600 + + +class Outcome(StrEnum): + """What running the ecosystem's resolver did.""" + + #: The lock moved and the version in it is the one that was asked for. + RESOLVED = "resolved" + #: The tool succeeded and the package did not move: the manifest's range forbids it. + CONSTRAINED = "constrained-by-manifest" + #: The tool failed. Its own output is carried. + FAILED = "failed" + #: A file the resolver needs is not in the checkout. + MISSING = "missing-manifest" + + +@dataclass(frozen=True) +class Result: + outcome: Outcome + detail: str = "" + + @property + def ok(self) -> bool: + return self.outcome is Outcome.RESOLVED + + +@dataclass(frozen=True) +class Resolver: + """One ecosystem's way of moving its own graph, declared as data. + + A table entry rather than a code path, so a new ecosystem is a row: `needs` is what must be in + the context for the tool to work at all, `image` is where the tool lives, and `command` is a + template. Nothing here knows what any of these tools do. + """ + + #: Which lock file this resolver owns. + lock: str + #: Everything that must be present, lock included. **The manifest is not optional**: every + #: resolver reads it to know what versions are allowed, and without it they resolve nothing. + needs: tuple[str, ...] + image: str + #: `{package}` and `{version}` are substituted. Run through `sh -lc`. + command: str + + +#: The ecosystems whose graphs can be moved, and how. +#: +#: `--package-lock-only` on npm moves the graph without downloading `node_modules`, which is the +#: difference between seconds and minutes. The two Python entries are separate resolvers rather +#: than one because `uv` and `poetry` disagree about everything except the file they read. +RESOLVERS: tuple[Resolver, ...] = ( + Resolver( + lock="package-lock.json", + needs=("package.json", "package-lock.json"), + image="node:22-slim", + command="npm install {package}@{version} --package-lock-only --no-audit --no-fund", + ), + Resolver( + lock="uv.lock", + needs=("pyproject.toml", "uv.lock"), + image="ghcr.io/astral-sh/uv:python3.12-bookworm-slim", + command="uv lock --upgrade-package {package}=={version}", + ), + Resolver( + lock="poetry.lock", + needs=("pyproject.toml", "poetry.lock"), + image="python:3.12-slim", + command=( + "pip install --quiet poetry && " + "poetry add {package}=={version} --lock --no-interaction" + ), + ), +) + + +def resolver_for(source: str) -> Resolver | None: + """The resolver that owns this lock file, or `None` when nothing does. + + `None` is not a gap to fill silently: item 173's refusal still applies to it, by name, so a + lock file nobody can move is still declined rather than edited by hand. + """ + name = source.rsplit("/", 1)[-1] + return next((r for r in RESOLVERS if r.lock == name), None) + + +def version_in_lock(text: str, lock: str, package: str) -> str | None: + """What the lock says this package is pinned at now, or `None` if it does not carry it. + + Read back **after** the resolver has run, because a tool that resolved within a range the + manifest allows exits 0 having moved nothing — and taking that as success would publish a + `clean` verdict for an upgrade that never happened. + """ + name = lock.rsplit("/", 1)[-1] + if name == "package-lock.json": + try: + document = json.loads(text) + except ValueError: + return None + packages = document.get("packages") + if not isinstance(packages, dict): + return None + for path, entry in packages.items(): + if not path or not isinstance(entry, dict): + continue + if path.split("node_modules/")[-1] == package: + found = entry.get("version") + return found if isinstance(found, str) else None + return None + + try: + document = tomllib.loads(text) + except tomllib.TOMLDecodeError: + return None + entries = document.get("package") + if not isinstance(entries, list): + return None + wanted = _canonical(package) + for entry in entries: + if isinstance(entry, dict) and _canonical(str(entry.get("name", ""))) == wanted: + found = entry.get("version") + return found if isinstance(found, str) else None + return None + + +def _canonical(name: str) -> str: + """PEP 503 again — `Jinja2` and `jinja-2` are one package, and lock files disagree on which.""" + import re + + return re.sub(r"[-_.]+", "-", name).lower() + + +def command_for(resolver: Resolver, package: str, version: str) -> str: + """The template filled in. Separate so it can be asserted without a daemon.""" + return resolver.command.format(package=package, version=version) + + +def missing_from(resolver: Resolver, present: Sequence[str]) -> list[str]: + """Which of the files this resolver needs are not in the checkout. + + Checked before the container starts: a `uv.lock` with no `pyproject.toml` beside it cannot be + resolved by anything, and finding that out after pulling an image is a minute wasted on a fact + that was on disk. + """ + names = {path.rsplit("/", 1)[-1] for path in present} + return [needed for needed in resolver.needs if needed not in names] + + +def upgrade( + *, + resolver: Resolver, + worktree: Path, + package: str, + version: str, + present: Sequence[str], + run: Callable[[Resolver, Path, str], tuple[int, str]], +) -> Result: + """Move the graph, then check that it actually moved. Item 175. + + `run` takes the resolver, the directory to mount and the command, and returns an exit code and + the tool's output. Injected for the reason every other boundary here is: this stays testable + without a daemon, and nothing in this module knows Docker exists. + """ + absent = missing_from(resolver, present) + if absent: + return Result( + Outcome.MISSING, + f"{resolver.lock} cannot be resolved without {', '.join(absent)}: the resolver reads " + f"the manifest to know which versions are allowed, and there is none here.", + ) + + code, output = run(resolver, worktree, command_for(resolver, package, version)) + if code != 0: + return Result(Outcome.FAILED, output) + + lock_path = worktree / resolver.lock + landed = version_in_lock(lock_path.read_text(encoding="utf-8"), resolver.lock, package) + if landed != version: + # **The tool's exit code is not the verdict.** Every one of these resolves happily within + # whatever range the manifest allows and reports success, so `^4.17.11` answers 0 having + # never gone near 5.x. Believing it would publish `clean` for an upgrade that never + # happened — the worst artefact this repository can emit. + return Result( + Outcome.CONSTRAINED, + f"the resolver exited 0 and {package} is still {landed or 'absent'}, not {version}: " + f"the range in the manifest does not allow it. Widen it there, then run this again.", + ) + return Result(Outcome.RESOLVED) + + +def in_a_container( + resolver: Resolver, context: Path, command: str, *, docker: str = "docker" +) -> tuple[int, str]: + """Run one resolver's command in an ephemeral container. The only Docker in this module. + + **A bind mount rather than a volume**, unlike an attempt's worktree (item 055), and the + difference is worth stating so it does not later look like an oversight. An attempt's phases run + **the project's own untrusted code**, where a bind mount would let it write to the host as the + uid that started it. This runs one package manager's own command with no project code executing, + and the entire purpose is to get a regenerated file back — which a bind mount does and a volume + does not. + + **`--user` is not a detail.** Without it `npm` leaves root-owned files in the operator's + checkout, and the next ordinary command they run fails with a permission error nothing connects + back to us. + + **And this one has a network, deliberately.** Resolving *is* asking the registry what exists. It + is the trade `image.build` already makes, and it changes nothing about the phase that later runs + the suite, which still reaches nothing. + """ + import os + import subprocess + + argv = [ + docker, "run", "--rm", + "--user", f"{os.getuid()}:{os.getgid()}", + # A resolver that hangs must not hold the run: these are network calls to a registry. + "--stop-timeout", "10", + "-v", f"{context}:/w", + "-w", "/w", + # `HOME` so the tools have somewhere to write their caches; `/w` is the only writable path + # and a cache in the checkout would be left behind for the operator to find. + "-e", "HOME=/tmp", + resolver.image, + "sh", "-lc", command, + ] + log.info("resolving", extra={"image": resolver.image, "command": command}) + try: + done = subprocess.run( # noqa: S603 + argv, capture_output=True, text=True, check=False, timeout=RESOLVE_TIMEOUT_SECONDS + ) + except subprocess.TimeoutExpired: + return 1, f"the resolver did not finish within {RESOLVE_TIMEOUT_SECONDS}s" + return done.returncode, (done.stdout + done.stderr).strip() + + +def touches(resolver: Resolver) -> tuple[str, ...]: + """Every file this resolver may rewrite, which is **all** of them and not just the lock. + + **Measured, not assumed** (item 175's gate, 2026-08-09): `npm install lodash@4.17.21 + --package-lock-only` rewrote `package.json` as well, moving its range from `^4.17.11` to + `^4.17.21`. That is correct behaviour for an upgrade and it is not what the caller expected. + + Why it matters more than it looks: item 174 found that a candidate leaving its own pin behind + made the *next* candidate's baseline describe the previous one, and fixed it by restoring the + file it had rewritten. With a resolver in the path there is more than one such file, and + restoring only the lock leaves the manifest moved — the same defect, one file over, and + invisible in exactly the same way. + """ + return resolver.needs diff --git a/hullwork/sandbox/net.py b/hullwork/sandbox/net.py index 7bebea3..b0f85b9 100644 --- a/hullwork/sandbox/net.py +++ b/hullwork/sandbox/net.py @@ -573,3 +573,48 @@ def _quietly(docker: str, argv: list[str]) -> None: run_docker([docker, *argv], timeout=DOCKER_TIMEOUT_SECONDS) except SandboxError as exc: # docker missing, or not answering log.warning("could not tear down", extra={"argv": argv, "error": str(exc)}) + + +def why_the_gateway_cannot_start(*, docker: str = "docker") -> str | None: + """The sentence that refuses an agent run before anything is paid for, or `None`. Item 191. + + **Shaped after `image.why_it_cannot_host_a_phase`, and here for the same reason it exists + there**: two doors needed a refusal and only the expensive one had it. Every agent path starts a + gateway — `work`, `try` and `deps --fix` — so a missing image is a fact about the instance and + not about the command that happened to notice. + + Measured on 2026-08-09, running `deps --fix` against a real model for the first time. It died + with `could not start the gateway / Unable to find image 'hullwork:dev' locally` **after** OSV, + four image builds and two suite runs. Item 048's finding and item 184's, a third time: the + refusal existed and happened in the most expensive place available. + + **A daemon that cannot be reached is a different answer**, and answering it here would be + guessing at somebody else's problem: `doctor` owns that question and says it properly. This one + answers only *is the image there*, and says nothing at all when the client is absent. + """ + import shutil + import subprocess + + if shutil.which(docker) is None: + # Not this function's question. `doctor` reports a missing or unreachable daemon, with the + # three things it can mean; a second opinion here would be a worse copy of it. + return None + + found = subprocess.run( # noqa: S603 + [docker, "image", "inspect", GATEWAY_IMAGE], + capture_output=True, + timeout=DOCKER_TIMEOUT_SECONDS, + check=False, + ) + if found.returncode == 0: + return None + return ( + f"the gateway image `{GATEWAY_IMAGE}` is not on this Docker daemon, and every agent run " + f"needs one.\n" + f" The gateway is where your model credential lives, so that the sandbox running the " + f"project's own code never holds it (DR-0004). It runs Hullwork's own image because it is " + f"Hullwork's own code.\n" + f" Build it from a checkout: docker build --tag {GATEWAY_IMAGE} .\n" + f" `docker compose build` does **not** make it: the compose file pins a published image " + f"and has no build stage." + ) diff --git a/hullwork/sandbox/run.py b/hullwork/sandbox/run.py index 707c4b8..4f6765d 100644 --- a/hullwork/sandbox/run.py +++ b/hullwork/sandbox/run.py @@ -130,8 +130,8 @@ #: Refused **on purpose**, which is a different thing from the accident above. A workflow file is #: code that runs on the forge's runner with the repository's secrets — outside the sandbox, with -#: privileges the agent does not have and must not be able to grant itself. Changing CI is already -#: an amber decision for a human in the worker contract; here it is simply not on the table. +#: privileges the agent does not have and must not be able to grant itself. The derived lane policy +#: already keeps a human on CI definitions (`territory.py`); here it is simply not on the table. FORBIDDEN_DIRS = frozenset({GIT_DIR, ".github", ".forgejo", ".gitea"}) #: Things a toolchain writes by itself, which are **not** the agent's work and must never be @@ -773,6 +773,40 @@ def is_test_infrastructure(relative: str) -> bool: return bool(_TEST_FILE.match(parts[-1])) +def created_test_config(worktree: Path, before: dict[str, bytes]) -> list[str]: + """Test configuration a phase **created** where the checkout had none. Item 179. + + **The hole in item 046's guard, found by writing this item's tests and confirmed against the + existing sequence.** `_restore_infrastructure` iterates the before-image, so it restores + configuration that was *edited* and never sees configuration that was *invented*: a fix phase + that writes a root `conftest.py` where a project had none switches the suite off, is not + restored, produces an empty `restored` — so no second gate runs — and publishes as `pr-open` + with the mechanism inside its own diff. Measured against `dispatch.dispatch` before this + existed, on a `conftest.py` carrying `collect_ignore_glob = ['*']` and no fix at all. + + Twenty-one tests in `test_dispatch` did not cover it, and they could not have: every one of + them expresses tampering as an *edit*, which is how item 046 was written and therefore how it + was tested. + + **Only configuration, never a new test.** `is_test_infrastructure` is deliberately not the + predicate here: it also matches new files under `tests/`, and a fix that adds a test is welcome + — that distinction is the whole reason the original guard iterated the snapshot. What is refused + is a *new* file whose only job is to decide whether the suite runs at all, and there is no + legitimate version of that arriving from a fix phase: the sandbox has no network, so a + dependency such a file could configure cannot be installed anyway. + """ + found: list[str] = [] + for path in sorted(worktree.rglob("*")): + if not path.is_file() or path.is_symlink(): + continue + relative = path.relative_to(worktree).as_posix() + if _forbidden(relative) or relative in before: + continue + if relative.split("/")[-1] in TEST_CONFIG_FILES: + found.append(relative) + return found + + @dataclass(frozen=True) class Changes: """What a phase did to the tree: files written, and files removed. diff --git a/hullwork/trial.py b/hullwork/trial.py index 915795c..2f84a88 100644 --- a/hullwork/trial.py +++ b/hullwork/trial.py @@ -255,6 +255,17 @@ def run( if refusal: raise work.WiringError(refusal) + # **Before the ephemeral database and before any image** (item 191). Every agent path starts a + # gateway, and `try` is the door the README sends a stranger to first — so it must not be the + # one that discovers a missing image at the most expensive moment. Same placement as the base + # image refusal above, and for the same reason. + from hullwork.sandbox.net import why_the_gateway_cannot_start + + if manifest.autofix.agent != "none": + missing = why_the_gateway_cannot_start() + if missing: + raise work.WiringError(missing) + session = ephemeral_session() project, item = stage(session, manifest, trace, repo=checkout.name) if item.state is ItemState.WAITING_APPROVAL and approve: diff --git a/hullwork/upgrades.py b/hullwork/upgrades.py new file mode 100644 index 0000000..f7f3356 --- /dev/null +++ b/hullwork/upgrades.py @@ -0,0 +1,220 @@ +"""Open the ones that pass. Item 178, DR-0018 step 3. + +This is where DR-0018's claim stops being a report and becomes the thing a reviewer receives: +*Renovate opens forty pull requests; we open the thirty-one that pass and tell you what to do with +the nine that do not.* + +**What it may open, and what it may never.** Only reports whose `needs_of` is `ready to take`. Not +the ones that break, not the blocked ones, not the ones whose baseline was red. A pull request from +Hullwork means *this was run and it passed*, and the moment it can mean anything else the claim is +worth nothing — including the ones it makes correctly. + +**One pull request per package, never a batch.** A grouped upgrade that breaks cannot be bisected by +the reviewer without undoing our work for us, and the verdict was computed per package anyway. + +**Nothing is remembered between runs, and nothing needs to be.** There is no database on this path: +the branch name carries the package and both versions, so a second pass over an unchanged repository +asks the forge for a branch that already exists and is told so. That is the same answer +`work.publish` has relied on since item 048, and it is better than a table — a table can disagree +with the forge, and this cannot. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Mapping, Sequence + +from hullwork import bump, evidence +from hullwork.forge import BranchExistsError, ForgeError +from hullwork.osv import Advisory + +log = logging.getLogger(__name__) + +#: Where these branches live. Namespaced under `hullwork/` like every other branch this product +#: creates, and under `deps/` beneath that so an operator can tell an upgrade from an agent's fix +#: without opening either. +BRANCH_PREFIX = "hullwork/deps" + +#: Everything git refuses in a ref name, plus the characters that merely make one awkward to type. +#: `@scope/pkg` is an ordinary npm name and `/` inside it would invent a directory level, so it goes +#: too — the branch has to be readable and it has to be creatable, and neither is negotiable. +_NOT_IN_A_REF = re.compile(r"[^A-Za-z0-9._-]+") + + +def branch_for(package: str, was: str, to: str) -> str: + """The branch this upgrade goes on, derived from the upgrade and nothing else. + + **Derived rather than allocated**, and that is what makes "nothing is opened twice" true without + any state: the same upgrade names the same branch on every run, for ever, so the forge is the + thing that remembers. An id from a counter would need a database, and a random suffix would open + the same pull request every hour. + + Both versions are in it because the pair is the work: next month's upgrade of the same package + is different work and gets its own branch. + """ + slug = "-".join(_NOT_IN_A_REF.sub("-", part).strip("-.") for part in (package, was, to)) + # A ref may not end in `.lock` nor contain `..`, and a name built from three sanitised parts + # cannot produce either — but it can produce a run of hyphens, which is legal and ugly. + return f"{BRANCH_PREFIX}/{re.sub('-{2,}', '-', slug)}" + + +def eligible(reports: Sequence[bump.Report]) -> list[bump.Report]: + """The reports that may be opened, which is one bucket of four. + + `needs_of` rather than a scan for a clean answer, for the reason item 179 found the hard way: a + report can carry a `breaks` answer *and* a later clean one, and asking what the report **needs** + is the only reading that puts that in the right bucket. + + **A clean answer with no files is refused rather than opened.** Those bytes are the diff, and + without them there would be a body making a claim about an empty commit — what was tested and + what is published have to be the same tree, which is what item 045 is named after. It can happen + honestly: an `Answer` built by hand, an older recording replayed. + """ + return [ + report + for report in reports + if bump.needs_of(report) is bump.Needs.JUST_TAKE_IT + and report.settled is not None + and report.settled.files + ] + + +def title_for(answer: bump.Answer) -> str: + """What the pull request is called. The upgrade, and that a suite was run — nothing else.""" + return f"deps: {answer.package} {answer.was} → {answer.to} (your suite passes)" + + +def commit_message_for(answer: bump.Answer, advisories: Sequence[Advisory]) -> str: + """One commit, saying what moved and why, without repeating the body. + + No DCO sign-off trailer, ever. `CONTRIBUTING.md` makes the sign-off a human act performed at the + merge gate, and that a machine *can* emit the trailer is exactly why it must not. + """ + named = ", ".join(a.id for a in advisories) + because = f"\n\nPublished against {answer.package} {answer.was}: {named}." if named else "" + return ( + f"deps: {answer.package} {answer.was} → {answer.to}\n\n" + f"This project's own test suite was run against this change in a sandbox and passed, " + f"having also passed before it. That is what was measured — not that the upgrade is safe." + f"{because}\n\n" + f"Opened by Hullwork." + ) + + +def open_them( + code_forge: object, + *, + repo: str, + reports: Sequence[bump.Report], + advisories: Mapping[str, Sequence[Advisory]], + base_sha: str, + permitted: bool, + secrets: list[str] | None = None, +) -> list[str]: + """Open one draft pull request per verified-green package. Returns where each one went. + + **`permitted` is required and has no default, which is the whole of DR-0019's guard.** It is the + project's `autofix.open_upgrades`, and item 017's rule is why it is a parameter of this function + rather than a check at the call site: *a guardrail that depends on every caller remembering it + is not a guardrail.* This is the only function in the product that opens anything, so a caller + who forgets gets a `TypeError` rather than an unguarded pull request in somebody's repository. + + **A failure on one does not cost the others.** A queue of five with one bad name has to be four + pull requests rather than a traceback, which is the same rule `publish` follows one layer up: + publishing is the last thing that happens and the only thing here that can fail after the + verdict already exists. + + `code_forge` is a parameter and is never built here, for the reason every other boundary in this + repository is: the credential belongs to whoever owns the process, and this module stays + testable against a double. + """ + if not permitted: + # **A decision somebody made, not a failure.** Logged rather than raised: the verification + # above is the valuable half and it already ran, so a refusal here ends the opening and + # nothing else. The caller says so in words; this is the record. + log.info( + "not opening: the project has not permitted it", + extra={"repo": repo, "eligible": len(eligible(reports))}, + ) + return [] + + opened: list[str] = [] + for report in eligible(reports): + answer = report.settled + assert answer is not None # noqa: S101 - `eligible` returns none without one + where = _open_one( + code_forge, + repo=repo, + answer=answer, + advisories=tuple(advisories.get(answer.package, ())), + base_sha=base_sha, + secrets=secrets, + ) + if where is not None: + opened.append(where) + return opened + + +def _open_one( + code_forge: object, + *, + repo: str, + answer: bump.Answer, + advisories: Sequence[Advisory], + base_sha: str, + secrets: list[str] | None, +) -> str | None: + """Branch, commit, draft pull request. `None` when there is nothing new to open.""" + from hullwork.work import _commit + + branch = branch_for(answer.package, answer.was, answer.to) + try: + # **Rooted at the sha the gates ran against**, never at whatever the default branch points + # at now. The base can move freely while a verification runs, and the pull request still + # contains precisely the tree the suite passed on. + code_forge.create_branch(repo, branch, base_sha) # type: ignore[attr-defined] + except BranchExistsError: + # The record of what was opened, kept by the forge rather than by us. A second pass over an + # unchanged repository lands here for every package it already dealt with, which is the + # whole of "nothing is opened twice". + log.info("already opened", extra={"branch": branch, "package": answer.package}) + return None + except ForgeError as exc: + log.warning( + "could not branch for an upgrade", + extra={"branch": branch, "package": answer.package, "error": str(exc)}, + ) + return None + + try: + _commit( + code_forge, repo, branch, + commit_message_for(answer, advisories), + dict(answer.files), + base_sha, + ) + pull = code_forge.open_draft_pull_request( # type: ignore[attr-defined] + repo, + head=branch, + base=code_forge.default_branch(repo), # type: ignore[attr-defined] + title=title_for(answer), + body=evidence.dependency_pull_request_body(answer, advisories, secrets=secrets), + ) + except ForgeError as exc: + # The branch exists and the pull request does not. Said rather than swallowed, because the + # next run will find the branch taken and open nothing — so this line is the only place + # anybody learns why that package never appeared. + log.warning( + "branched but could not open the pull request", + extra={"branch": branch, "package": answer.package, "error": str(exc)}, + ) + return None + + if not pull.draft: + # Forgejo derives draft from a title prefix an instance can reconfigure and no API exposes + # (spec §5.1), so the response is read back rather than assumed. A merge-ready pull request + # from a bot is the one artefact this product must never leave behind. + log.error("the forge did not mark it a draft", extra={"pull": pull.ref}) + log.info("opened", extra={"package": answer.package, "to": answer.to, "pull": pull.ref}) + return str(pull.html_url) diff --git a/hullwork/upstream.py b/hullwork/upstream.py index 489be5e..6fb360e 100644 --- a/hullwork/upstream.py +++ b/hullwork/upstream.py @@ -113,7 +113,7 @@ | { f"cli:{name}" for name in ( - "approve", "config", "doctor", "gateway", "init", "lease", + "approve", "config", "deps", "doctor", "features", "gateway", "init", "lease", "page-token", "password", "projects", "propose", "prune", "republish", "requeue", "status", "sweep", "try", "work", ) diff --git a/hullwork/webhooks.py b/hullwork/webhooks.py index 481b62e..d3eaed8 100644 --- a/hullwork/webhooks.py +++ b/hullwork/webhooks.py @@ -158,20 +158,36 @@ async def receive( def _authenticate(provider: str, token: str, raw: bytes, expected_hash: str) -> bool: - """Per provider, because they do not offer the same thing. - - GlitchTip cannot sign its webhooks — no header, no secret, no setting — so the token in the path - is the credential. Sentry does sign, and its route is not enabled yet: verifying an HMAC means - holding its client secret in a **reversible** form, which is a different storage decision from - the one-way hash used here and deserves to be made deliberately rather than in passing. + """**The token in the path is the credential, for both providers.** Item 189, DR-0009. + + GlitchTip cannot sign its webhooks — no header, no secret, no setting — so the token in the URL + has been the credential since M1, verified against a one-way hash. + + **Sentry does sign, and this deliberately does not check it.** Verifying its HMAC means holding + its client secret in a **reversible** form, which is a different storage decision from every + other credential here and has not been made. This route answered `501` for that reason, and the + reason was written as *reversible secret or nothing* — which left out the option the operator + took on 2026-08-09: give Sentry **the same credential GlitchTip has**, checked the same way. + The route was refusing to offer a guarantee better than the one the only working provider gets, + which is coherent only if a signature is mandatory — and if it were, GlitchTip could not be + enabled either. + + **What that does not protect against, said plainly.** An HMAC would authenticate the *body*, so + somebody who obtained the URL — from a log, a proxy, a referrer — but not Sentry's client secret + could not post. Here, knowing the URL is enough, because the URL contains the credential. That + is the exposure GlitchTip users have had all along; this extends it to a second provider rather + than creating it, and verifying the HMAC stays open as the upgrade. + + `raw` is unread on purpose, and stays in the signature: it is what an HMAC would be computed + over, and removing the parameter would make adding that verification a change to every caller + rather than a change to this function. """ - if provider == "glitchtip": + del raw + if provider in ("glitchtip", "sentry"): + # **One expression for both**, so the two refusals cannot come to differ. A wrong token has + # to look identical on either route: a difference is a way to confirm which provider a slug + # is registered with, by probing, from outside (item 122's rule for the page, here). return verify_token(token, expected_hash) - if provider == "sentry": - raise HTTPException( - status.HTTP_501_NOT_IMPLEMENTED, - "the Sentry route is not enabled in this version; use GlitchTip", - ) return False diff --git a/hullwork/work.py b/hullwork/work.py index 1e5efc6..384ddb9 100644 --- a/hullwork/work.py +++ b/hullwork/work.py @@ -920,6 +920,11 @@ def run_one( image_tag: str | None = None, base_sha: str | None = None, production_ref: str | None = None, + #: Which sequence to run in the box. `None` is `dispatch.dispatch`, the six steps. Item 179 + #: passes `dispatch.refit`, which is three — and everything around it here is the same, which + #: is the point: the claim, the seal, the ceiling checks, publication and release are about an + #: attempt rather than about what the attempt was for. + sequence: object = None, ) -> Outcome: """Claim, dispatch, publish, record. The order is the whole design. @@ -951,8 +956,9 @@ def run_one( try: box = box_factory(manifest) # type: ignore[operator] - verdict = dispatch_module.dispatch( - session, item, manifest, engine, # type: ignore[arg-type] + run = sequence or dispatch_module.dispatch + verdict = run( # type: ignore[operator] + session, item, manifest, engine, box=box, attempt=attempt, ) except dispatch_module.Abandoned as stop: @@ -1263,7 +1269,10 @@ def publish( outcome = attempt.outcome try: if outcome not in (AttemptOutcome.PR_OPEN, AttemptOutcome.PR_OPEN_LINT_FAILED): - return _comment(forge, repo=repo, item=item, attempt=attempt, secrets=secrets) + return _comment( + forge, repo=repo, item=item, attempt=attempt, secrets=secrets, + claim=str(getattr(verdict, "claim", "")), + ) branch = evidence.branch_name(item, attempt) try: @@ -1300,6 +1309,10 @@ def publish( item, attempt, detail=str(getattr(verdict, "detail", "")), brief_text=brief_text, brief_evidence=brief_evidence, secrets=secrets, prices=prices, + # Item 179: a sequence whose claim is not the ordinary one carries its own, and both + # publishers read it from the same place so the page and the pull request cannot come + # to disagree about what was measured. + claim=str(getattr(verdict, "claim", "")), ) pull = code_forge.open_draft_pull_request( # type: ignore[attr-defined] repo, @@ -1361,6 +1374,7 @@ def publisher(item: Item, attempt: Attempt, verdict: object) -> str | None: (into / "artefact.md").write_text( evidence.pull_request_body( item, attempt, detail=str(getattr(verdict, "detail", "")), + claim=str(getattr(verdict, "claim", "")), ), encoding="utf-8", ) @@ -1523,6 +1537,11 @@ def _attempt( secrets: list[str], rehearse_into: Path | None = None, local_checkout: "Checkout | None" = None, + #: The dependency upgrade this attempt is about, when it is one (item 179). It changes three + #: things and nothing else: which sequence runs, what the brief says, and what the artefact + #: can claim about the evidence the agent had. One parameter rather than three, because those + #: three have to agree and a caller that sets two of them has built a lie. + upgrade: object = None, ) -> Outcome: """Build one attempt's world, run the sequence in it, and take the world down again. @@ -1531,6 +1550,7 @@ def _attempt( variable are how the second item in a run ends up dispatched into the first one's container. """ from contextlib import ExitStack + from functools import partial from hullwork import dispatch as dispatch_module from hullwork import engine as engine_module @@ -1643,11 +1663,40 @@ def _attempt( contract_dir = Path(tempfile.mkdtemp(prefix="hullwork-contract-")) stack.callback(shutil.rmtree, contract_dir, ignore_errors=True) - dispatch_module.build_brief_file(session, item, contract_dir) - brief_text = build_brief(session, item) - # Read from the same event the brief was built from, before the attempt runs — enrichment - # can happen while it does, and the artefact has to say what the agent *had* (item 100). - brief_evidence = brief_evidence_level(session, item) + sequence: object = None + if upgrade is None: + dispatch_module.build_brief_file(session, item, contract_dir) + brief_text = build_brief(session, item) + # Read from the same event the brief was built from, before the attempt runs — + # enrichment can happen while it does, and the artefact has to say what the agent + # *had* (item 100). + brief_evidence = brief_evidence_level(session, item) + else: + # Item 179. Everything below this block is untouched: same image, same gates, same + # seal, same publisher. What a refit replaces is what the agent is told and which + # sequence reads its work — the two halves that are about a bug rather than about an + # attempt. + from hullwork import refit as refit_module + + brief_text = refit_module.brief(upgrade) # type: ignore[arg-type] + dispatch_module.write_brief(brief_text, contract_dir) + # **Not `brief.evidence_level`**, which reads a `FetchedEvent` this item does not have + # and would answer "the issue title only — the tracker was never asked". That sentence + # exists to warn a reviewer that an attempt ran on almost nothing; here it would + # understate the best evidence this product produces (item 100's rule, held to). + brief_evidence = ( + f"the upgrade, and the {len(brief_text.splitlines())}-line brief naming the tests " + f"your own suite failed on with it applied" + ) + sequence = partial( + dispatch_module.refit, + package=upgrade.package, # type: ignore[attr-defined] + to=upgrade.to, # type: ignore[attr-defined] + guarded=upgrade.guarded, # type: ignore[attr-defined] + version_now=lambda tree: refit_module.version_now( + upgrade, tree # type: ignore[arg-type] + ), + ) # The gateway runs **in** the attempt's own network, not on this host (item 054). A # container on an `--internal` network cannot reach a listener on the host — measured on a @@ -1750,6 +1799,7 @@ def publisher(published: Item, attempt: Attempt, verdict: object) -> str | None: base_sha=checked_out.sha, production_ref=_production_ref(session, item), rehearsal=rehearsal, + sequence=sequence, ) # The seal that was stored, not a third read of the journal. Two reads of a growing file are # two chances to print something the database does not say. @@ -1844,7 +1894,8 @@ def _production_ref(session: Session, item: Item) -> str | None: def _comment( - forge: object, *, repo: str, item: Item, attempt: Attempt, secrets: list[str] | None + forge: object, *, repo: str, item: Item, attempt: Attempt, secrets: list[str] | None, + claim: str = "", ) -> str | None: """Say on the issue what happened, with the ingest credential rather than the code one. @@ -1858,7 +1909,9 @@ def _comment( log.info("no issue to report to", extra={"item": item.id}) return None number = int(item.forge_issue_ref.lstrip("#")) - body = evidence.issue_comment(item, attempt, detail=attempt.error or "", secrets=secrets) + body = evidence.issue_comment( + item, attempt, detail=attempt.error or "", secrets=secrets, claim=claim + ) forge.comment(repo, number, body) # type: ignore[attr-defined] log.info("commented on the issue", extra={"item": item.id, "issue": number}) return item.forge_issue_ref diff --git a/images/banner.svg b/images/banner.svg index d1f3650..a00ff2e 100644 --- a/images/banner.svg +++ b/images/banner.svg @@ -1,11 +1,11 @@ - Hullwork — from production errors to reviewable draft pull requests + Hullwork — verifies which of your tools' claims are actually true The Hullwork wordmark beside its mark: two filled quadrants, diagonally opposed. Under - it, the promise — from production errors to reviewable draft pull requests, self-hosted, with your - forge, your error tracker, your model endpoint and a human gate on every merge. Along the bottom, - the three counts from one real attempt: 904 passed untouched, 2 failed with the new test and no - fix, 906 passed with the fix applied. + it, the claim — it verifies which of your tools' claims are actually true, before a person is + asked — and then: self-hosted, with your forge, your error tracker, your model endpoint and a + human gate on every merge. Along the bottom, the three counts from one real attempt: 904 passed + untouched, 2 failed with the new test and no fix, 906 passed with the fix applied. + Verifies which of your tools’ claims are actually true, before a person is asked. Self-hosted · your forge · your error tracker · your model endpoint · a human gate on every merge diff --git a/images/social-preview.png b/images/social-preview.png index f4c5bd760b6623c31e8af23d97e0d77eea84be2a..55028351cbf95919427ed144473def287f0f6acd 100644 GIT binary patch literal 78223 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

    LJR*~<>CS8q?>WVZ=!5M$D7U=_DU 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 literal 66643 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 Hhk*YDX~_4g diff --git a/images/social-preview.svg b/images/social-preview.svg new file mode 100644 index 0000000..6102365 --- /dev/null +++ b/images/social-preview.svg @@ -0,0 +1,77 @@ + + Hullwork — verifies which of your tools' claims are actually true + The Hullwork wordmark above the claim: it verifies which of your tools' claims are + actually true, before a person is asked — errors, dependencies, incidents. Below, the three + counts from one real attempt: the suite untouched, 904 passed; the new test with no fix, 2 + failed, exit 1, the required outcome; the fix applied, 906 passed. Along the bottom: self-hosted, + your forge, your error tracker, your model endpoint, a human gate on every merge — and the + project's state, pre-alpha, under FSL-1.1-ALv2. + + + + + + + + + + + + + hullwork + + Verifies which of your tools’ + claims are actually true. + Before a person is asked — errors, dependencies, incidents. + + + + + + the suite, untouched + 904 passed + green, so an attempt may start + + + the new test, no fix + 2 failed + exit 1 — the required outcome + + + the fix applied + 906 passed + and the 904 still pass + + + + Self-hosted. Your forge, your error tracker, your model endpoint. + A human gate on every merge. + pre-alpha · FSL-1.1-ALv2 + diff --git a/images/the-pipeline.svg b/images/the-pipeline.svg index 1209ad6..dbf8a18 100644 --- a/images/the-pipeline.svg +++ b/images/the-pipeline.svg @@ -1,7 +1,8 @@ - What Hullwork does, from a production error to a deploy - Two things arrive: a production error, from GlitchTip or anything Sentry-compatible, by + The path a production error takes, from the webhook to a deploy + One of Hullwork's three signals, end to end — the only one in a release. Two things + arrive: a production error, from GlitchTip or anything Sentry-compatible, by webhook; and a human report by email or chat, through a normaliser. Hullwork triages, deduplicates and assigns a risk lane. That becomes a work item with a lane. A green item goes to a coding agent in a sandbox, using your model key. The agent's work becomes a draft pull request. A person reviews diff --git a/pyproject.toml b/pyproject.toml index bb1a412..7080719 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,13 @@ build-backend = "hatchling.build" [project] name = "hullwork" -description = "From production errors to reviewable pull requests, on your own infrastructure." +# **Rewritten 2026-08-09 (item 181).** What it said before named one signal's two endpoints, offered +# in the first line PyPI shows — where a reader decides what this is, and answering a narrower +# question than the product does. `docs/what-hullwork-is.md` is the canonical statement and this is +# its one-line form. The previous wording is in item 181 and in the commit that changed it; it is +# deliberately not quoted here, because the guard that keeps it out of published documents is a +# plain text search and a guard that has to be clever is a guard nobody trusts. +description = "Verifies which of the things your tools claim are actually true, before a person is asked. Self-hosted." readme = "README.md" # **Narrowed 2026-08-04, because the wider range was a claim this project did not meet.** # 3.13 and 3.14 are red: `filterwarnings = ["error"]` turns unclosed SQLite connections into diff --git a/tests/test_bump.py b/tests/test_bump.py new file mode 100644 index 0000000..9fe04f0 --- /dev/null +++ b/tests/test_bump.py @@ -0,0 +1,563 @@ +"""Does the upgrade survive the project's own suite. Item 173, DR-0016. + +**No test here needs Docker**, and that is the same trade `dispatch` makes: the function is handed +a box and a directory, so a double serves it. The Docker path is measured once by hand and written +into the item, because a mocked container proves the wiring and not the claim. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hullwork import bump +from hullwork.sandbox.run import RunResult + + +class FakeBox: + """A sandbox that answers with whatever the test queued, in order.""" + + def __init__(self, worktree: Path, *results: RunResult) -> None: + self.worktree = worktree + self._results = list(results) + self.commands: list[str] = [] + + def run(self, command: str, timeout: int = 0) -> RunResult: + del timeout + self.commands.append(command) + return self._results.pop(0) + + +def _ok(output: str = "12 passed") -> RunResult: + return RunResult(command="pytest", exit_code=0, output=output, duration_ms=10) + + +def _red(output: str = "FAILED tests/test_a.py::test_one\n1 failed") -> RunResult: + return RunResult(command="pytest", exit_code=1, output=output, duration_ms=10) + + +def _checkout(tmp_path: Path, text: str = "jinja2==2.4.1\n") -> Path: + (tmp_path / "requirements.txt").write_text(text, encoding="utf-8") + return tmp_path + + +# --- the edit ----------------------------------------------------------------------------- + + +def test_a_pin_is_rewritten_and_everything_else_on_the_line_survives() -> None: + """Extras, environment markers and trailing comments all outlive the upgrade. + + Only the version group is replaced, which is why this works at all — a line rebuilt from its + parsed parts would quietly drop the marker and change what gets installed on other platforms. + """ + line = 'httpx[http2]==0.27.0 ; python_version >= "3.8" # pinned by hand\n' + out = bump.rewrite_pin(line, "httpx", "0.28.1") + + assert out == 'httpx[http2]==0.28.1 ; python_version >= "3.8" # pinned by hand\n' + + +def test_only_the_named_package_moves() -> None: + text = "jinja2==2.4.1\nrequests==2.31.0\njinja2-time==0.2.0\n" + out = bump.rewrite_pin(text, "jinja2", "2.10.1") + + assert "jinja2==2.10.1" in out + assert "requests==2.31.0" in out + assert "jinja2-time==0.2.0" in out, "a longer name that starts the same is a different package" + + +def test_a_hashed_pin_is_refused_rather_than_broken() -> None: + """The hash describes the artefact pinned. Change the version and it will not install.""" + text = "jinja2==2.4.1 --hash=sha256:abc\n" + with pytest.raises(bump.CannotRewriteError, match="hash"): + bump.rewrite_pin(text, "jinja2", "2.10.1") + + +def test_a_package_that_is_not_pinned_here_is_not_silently_ignored() -> None: + with pytest.raises(bump.CannotRewriteError, match="no `django==…` line"): + bump.rewrite_pin("jinja2==2.4.1\n", "django", "5.0") + + +@pytest.mark.parametrize("name", ["Cargo.lock", "go.sum", "Gemfile.lock", "composer.lock"]) +def test_a_lock_with_no_resolver_is_still_refused_by_name(name: str) -> None: + """**The allow-list is the point.** + + Item 175 gave `package-lock.json`, `uv.lock` and `poetry.lock` a resolver, so those are no + longer refused. Everything else still is — and stating the rule as *only lists are editable* + rather than as three named refusals is what makes that true for a lock file nobody has taught + this about yet. The unsafe answer must never be the default. + """ + with pytest.raises(bump.CannotRewriteError) as caught: + bump.can_rewrite(f"path/to/{name}") + + assert name in str(caught.value) + assert "cannot install" in str(caught.value) + + +@pytest.mark.parametrize("name", ["package-lock.json", "uv.lock", "poetry.lock"]) +def test_a_lock_with_a_resolver_is_no_longer_refused(name: str) -> None: + """Item 175 lifted the refusal for these: their own tool can move the graph.""" + bump.can_rewrite(f"path/to/{name}") + + +def test_requirements_is_not_refused() -> None: + bump.can_rewrite("requirements.txt") + bump.can_rewrite("deep/nested/requirements.txt") + + +@pytest.mark.parametrize( + "name", + [ + "requirements/base.txt", + "requirements/prod.txt", + "requirements-dev.txt", + "dev-requirements.txt", + "backend/requirements/test.txt", + ], +) +def test_every_layout_the_reader_accepts_is_editable_by_hand(name: str) -> None: + """The consequence item 180 created, caught before it shipped. + + Widening the reader without widening this made `can_rewrite` refuse every one of these — and + refuse them with a sentence that is **false**: *"it is a resolved graph rather than a list of + versions"*. They are lists of versions; that is the whole reason the reader can read them. The + cost would have been the entire `--verify` / `--open` / `--fix` chain going quiet on any project + using a layout other than a root `requirements.txt`, with a wrong reason printed for each. + + One predicate for both, so a layout that becomes readable becomes editable in the same edit. + """ + bump.can_rewrite(name) + + +def test_a_hash_pinned_line_is_still_refused_by_name_in_any_layout() -> None: + """The refusal that existed and had never had the chance to fire. Item 180's last criterion. + + `requirements/build.txt` in this repository pins by hash, and until the reader read that file + nothing could reach this. Now that something can, the refusal has to be the one about hashes — + specific and actionable — rather than the generic one about resolved graphs. + """ + with pytest.raises(bump.CannotRewriteError, match="hash"): + bump.rewrite_pin("build==1.2.1 --hash=sha256:abc\n", "build", "1.3.0") + + +# --- the three phases --------------------------------------------------------------------- + + +def test_a_suite_that_is_already_red_stops_before_anything_is_rewritten(tmp_path: Path) -> None: + """**Before the edit and before a second build is paid for.** + + A suite already failing cannot support "passed before and passes after", and blaming the + upgrade for it is the error `dispatch` made until item 043. + """ + checkout = _checkout(tmp_path) + box = FakeBox(checkout, _red()) + rebuilt: list[str] = [] + + answer = bump.attempt( + lambda: box, tests="pytest", source="requirements.txt", package="jinja2", + was="2.4.1", to="2.10.1", rebuild=rebuilt.append, + ) + + assert answer.verdict is bump.Verdict.ALREADY_RED + assert rebuilt == [], "nothing may be rebuilt once the baseline is red" + assert (checkout / "requirements.txt").read_text() == "jinja2==2.4.1\n", "not rewritten" + assert "FAILED tests/test_a.py::test_one" in answer.detail + + +def test_a_clean_upgrade_says_exactly_what_it_measured(tmp_path: Path) -> None: + """**The wording is asserted so it cannot drift.** + + DR-0016 fixes it: *the suite passed before this change and passes after it*. Never "safe", and + never "fixes the vulnerability" — a suite that never exercised the library says so by staying + green, and widening the claim here is the defect item 171 removed. + """ + checkout = _checkout(tmp_path) + box = FakeBox(checkout, _ok(), _ok()) + + answer = bump.attempt( + lambda: box, tests="pytest", source="requirements.txt", package="jinja2", + was="2.4.1", to="2.10.1", rebuild=lambda text: None, + ) + + assert answer.verdict is bump.Verdict.CLEAN + assert "passed before this change and passes after it" in answer.says + assert "not that the upgrade is safe" in answer.says + # **The tree is left as it was found, even on a clean verdict.** This measures; it does not + # apply. Leaving the rewrite in place would make one candidate's result describe the next + # one's baseline — which is what a real run did, reporting `already-red` about a suite that + # had been green a minute earlier. + assert (checkout / "requirements.txt").read_text() == "jinja2==2.4.1\n" + + +def test_a_clean_verdict_carries_the_file_the_passing_run_actually_saw(tmp_path: Path) -> None: + """The seam between measuring an upgrade and opening one. Item 178. + + **These bytes exist for about two lines.** The tree is restored on the way out — the test above + asserts that, and it has to stay true — so anything that wants to publish what passed has to be + handed it before the restore. Working the diff out afterwards would mean running the resolver a + second time, and a lock regenerated twice can differ: a version published in between, a + different ordering, a registry that answered differently. Publishing files that are not the ones + the suite passed against is the defect item 045 is named after. + + Verified by reintroducing the defect, and it is the worst kind: with these dropped, `upgrades` + finds nothing eligible and `deps --open` opens nothing at all — silently, with every other test + in both files still green. + """ + checkout = _checkout(tmp_path) + box = FakeBox(checkout, _ok(), _ok("248 passed in 30.44s")) + + answer = bump.attempt( + lambda: box, tests="pytest", source="requirements.txt", package="jinja2", + was="2.4.1", to="2.10.1", rebuild=lambda text: None, + ) + + assert answer.files == {"requirements.txt": b"jinja2==2.10.1\n"}, "the upgraded file, not the " + # …and the tree it was read from is back to what it was, which is what makes the two facts + # different rather than redundant. + assert (checkout / "requirements.txt").read_text() == "jinja2==2.4.1\n" + assert answer.runs is not None + assert (answer.runs.before_exit, answer.runs.after_exit) == (0, 0) + assert answer.runs.after_summary == "248 passed in 30.44s" + assert answer.runs.command == "pytest" + + +def test_a_verdict_that_is_not_clean_carries_no_files_to_publish(tmp_path: Path) -> None: + """Nothing that broke has a tree anybody should be offered. Item 178's rule, at the source.""" + checkout = _checkout(tmp_path) + box = FakeBox(checkout, _ok(), _red()) + + answer = bump.attempt( + lambda: box, tests="pytest", source="requirements.txt", package="jinja2", + was="2.4.1", to="2.10.1", rebuild=lambda text: None, + ) + + assert answer.verdict is bump.Verdict.BREAKS + assert answer.files == {} + # The runs are still carried: a reader of the report wants the exit codes either way. + assert answer.runs is not None and answer.runs.after_exit == 1 + + +def test_a_breaking_upgrade_names_the_tests_it_broke(tmp_path: Path) -> None: + """The finding, and the reason anybody would install this.""" + checkout = _checkout(tmp_path) + box = FakeBox(checkout, _ok(), _red("FAILED tests/test_render.py::test_escape\n1 failed")) + + answer = bump.attempt( + lambda: box, tests="pytest", source="requirements.txt", package="jinja2", + was="2.4.1", to="2.10.1", rebuild=lambda text: None, + ) + + assert answer.verdict is bump.Verdict.BREAKS + assert "tests/test_render.py::test_escape" in answer.detail + assert "breaks your suite" in answer.says + + +def test_a_build_that_fails_is_not_the_same_as_a_suite_that_fails(tmp_path: Path) -> None: + """And the file is put back, so a refused upgrade leaves the checkout as it was found.""" + checkout = _checkout(tmp_path) + box = FakeBox(checkout, _ok()) + + answer = bump.attempt( + lambda: box, tests="pytest", source="requirements.txt", package="jinja2", + was="2.4.1", to="2.10.1", + rebuild=lambda text: "no matching distribution found for jinja2==2.10.1", + ) + + assert answer.verdict is bump.Verdict.WILL_NOT_INSTALL + assert "no matching distribution" in answer.detail + assert (checkout / "requirements.txt").read_text() == "jinja2==2.4.1\n", "put back" + + +def test_the_rebuild_is_handed_the_rewritten_text(tmp_path: Path) -> None: + """The caller builds the image, and it must build the file that will be tested. + + Reintroducing this defect — handing `rebuild` the original text — produced a green verdict for + an upgrade that had never been installed, which is the worst answer this module could give. + """ + checkout = _checkout(tmp_path) + box = FakeBox(checkout, _ok(), _ok()) + seen: list[str] = [] + + def rebuild(text: str) -> None: + seen.append(text) + + bump.attempt( + lambda: box, tests="pytest", source="requirements.txt", package="jinja2", + was="2.4.1", to="2.10.1", rebuild=rebuild, + ) + + assert seen == ["jinja2==2.10.1\n"], "the build must see the upgrade, not the old pin" + + +def test_a_lock_file_is_refused_before_the_baseline_is_paid_for(tmp_path: Path) -> None: + """No build, no suite run: a project whose only pins are locked is told at once.""" + box = FakeBox(tmp_path, _ok()) + + with pytest.raises(bump.CannotRewriteError): + bump.attempt( + lambda: box, tests="pytest", source="Cargo.lock", package="jinja2", + was="2.4.1", to="2.10.1", rebuild=lambda text: None, + ) + + assert box.commands == [], "nothing may run before the file is known to be rewritable" + + +def test_two_spellings_of_one_package_are_one_package() -> None: + """PEP 503: `Jinja2`, `jinja_2` and `jinja.2` all name the same distribution. + + OSV answers with the canonical name, and a requirements file carries whichever spelling its + author typed. Comparing raw strings refuses to rewrite a pin that is plainly there — which + reads as "no such dependency" about a line the reader can see. + """ + assert bump.rewrite_pin("Jinja2==2.4.1\n", "jinja2", "2.10.1") == "Jinja2==2.10.1\n" + assert bump.rewrite_pin("ruamel_yaml==0.1\n", "ruamel-yaml", "0.2") == "ruamel_yaml==0.2\n" + # And the spelling the project chose survives the rewrite: this edits a file a person owns. + assert "Jinja2" in bump.rewrite_pin("Jinja2==2.4.1\n", "jinja2", "2.10.1") + + +# --- trying the candidates ------------------------------------------------------------------ + + +class _Advisory: + def __init__(self, *fixed: str) -> None: + self.fixed = fixed + + +def test_the_candidates_are_every_published_fix_without_repeats() -> None: + """Item 172's deferred question, answered by execution rather than by comparison. + + That item prints every fixed version and chooses none, because choosing means comparing + versions under two ecosystems' rules. Here each is tried and the suite decides. + """ + assert bump.candidates([_Advisory("2.11.3", "3.1.3"), _Advisory("2.11.3")]) == [ + "2.11.3", + "3.1.3", + ] + + +def test_it_stops_at_the_first_candidate_that_leaves_the_suite_green(tmp_path: Path) -> None: + """**And does not keep going.** + + The remaining candidates are higher versions of the same fix, and upgrading further than the + advisory asks for is taking a larger change than the problem requires. + """ + tried: list[str] = [] + + def make_box(version: str) -> bump.Box: + # Called twice per candidate — once for the baseline, once for the rebuilt image — so each + # box answers exactly one run, which is what a real box does. + tried.append(version) + _checkout(tmp_path) + first = tried.count(version) == 1 + if first: + return FakeBox(tmp_path, _ok()) + return FakeBox(tmp_path, _red("FAILED tests/test_x.py::test_y") if version == "2.11.3" + else _ok()) + + report = bump.verify( + tests="pytest", source="requirements.txt", package="jinja2", was="2.4.1", + versions=["2.11.3", "3.1.3", "9.9.9"], make_box=make_box, rebuild=lambda text: None, + ) + + assert tried == ["2.11.3", "2.11.3", "3.1.3", "3.1.3"], "9.9.9 must never be attempted" + assert report.settled is not None + assert report.settled.to == "3.1.3" + assert [a.verdict for a in report.answers] == [bump.Verdict.BREAKS, bump.Verdict.CLEAN] + + +def test_a_red_baseline_does_not_burn_through_every_candidate(tmp_path: Path) -> None: + """The suite is broken, not the candidate. Asking it again gets the same answer. + + Reintroducing this ran a full build per published version against a suite that could never + answer — on lodash's seven advisories that is seven builds to learn nothing. + """ + tried: list[str] = [] + + def make_box(version: str) -> bump.Box: + tried.append(version) + _checkout(tmp_path) + return FakeBox(tmp_path, _red()) + + report = bump.verify( + tests="pytest", source="requirements.txt", package="jinja2", was="2.4.1", + versions=["2.11.3", "3.1.3"], make_box=make_box, rebuild=lambda text: None, + ) + + assert tried == ["2.11.3"], "a red baseline is about the project, not the candidate" + assert report.settled is None + assert report.answers[0].verdict is bump.Verdict.ALREADY_RED + + +def test_nothing_clean_means_nothing_settled(tmp_path: Path) -> None: + calls: list[str] = [] + + def make_box(version: str) -> bump.Box: + _checkout(tmp_path) + calls.append(version) + return FakeBox(tmp_path, _ok() if calls.count(version) == 1 else _red()) + + report = bump.verify( + tests="pytest", source="requirements.txt", package="jinja2", was="2.4.1", + versions=["2.11.3", "3.1.3"], make_box=make_box, rebuild=lambda text: None, + ) + + assert report.settled is None + assert len(report.answers) == 2, "every candidate is tried when none is clean" + + +def test_the_second_run_happens_in_a_box_the_rebuild_produced(tmp_path: Path) -> None: + """**The defect a real Docker run found and nineteen unit tests did not.** + + `attempt` used to take a box rather than a factory, so the green gate ran in the container + built *before* the upgrade: the suite of an upgraded project measured against the environment + it replaced. It reported `clean` for `jinja2 3.0.3 → 3.1.6` against a suite importing + `jinja2.Markup`, which 3.1 removed — a green verdict for a version that was never installed. + + A double has no image, which is exactly why nothing in this file could have noticed. What it + *can* assert is the shape that made it possible: the factory is called again after the + rebuild, and the second run happens somewhere else. + """ + _checkout(tmp_path) + handed: list[FakeBox] = [] + order: list[str] = [] + + def make_box() -> bump.Box: + box = FakeBox(tmp_path, _ok()) + handed.append(box) + return box + + def rebuild(text: str) -> None: + order.append("rebuild") + + bump.attempt( + make_box, tests="pytest", source="requirements.txt", package="jinja2", + was="2.4.1", to="2.10.1", rebuild=rebuild, + ) + + assert len(handed) == 2, "the box the baseline ran in cannot be the box the verdict runs in" + assert handed[0] is not handed[1] + assert [len(b.commands) for b in handed] == [1, 1], "one run each, in its own environment" + + +def test_no_second_box_is_built_when_the_rebuild_failed(tmp_path: Path) -> None: + """There is nothing to run in: the image the verdict needs does not exist.""" + _checkout(tmp_path) + handed: list[FakeBox] = [] + + def make_box() -> bump.Box: + box = FakeBox(tmp_path, _ok()) + handed.append(box) + return box + + answer = bump.attempt( + make_box, tests="pytest", source="requirements.txt", package="jinja2", + was="2.4.1", to="2.10.1", rebuild=lambda _text: "no matching distribution", + ) + + assert answer.verdict is bump.Verdict.WILL_NOT_INSTALL + assert len(handed) == 1 + + +def test_a_broken_candidate_does_not_describe_the_next_one(tmp_path: Path) -> None: + """**The second defect the real Docker run found.** + + A candidate that breaks the suite used to leave its own pin in the tree, so the next + candidate's baseline ran against it and reported `already-red` — about a suite that had been + green a minute before. One candidate must not be able to describe the next. + """ + _checkout(tmp_path) + seen: list[str] = [] + + def make_box(version: str) -> bump.Box: + seen.append((tmp_path / "requirements.txt").read_text()) + return FakeBox(tmp_path, _ok() if len(seen) % 2 else _red()) + + bump.verify( + tests="pytest", source="requirements.txt", package="jinja2", was="2.4.1", + versions=["2.11.3", "3.1.3"], make_box=make_box, rebuild=lambda _t: None, + ) + + baselines = [text for i, text in enumerate(seen) if i % 2 == 0] + assert all(t == "jinja2==2.4.1\n" for t in baselines), ( + f"every candidate must start from the original pin, saw {baselines}" + ) + + +# --- the ranked report. DR-0018 step 2. ------------------------------------------------------- + + +def _report(package: str, *answers: bump.Answer) -> bump.Report: + return bump.Report(package, "1.0.0", answers) + + +def _answer(verdict: bump.Verdict, detail: str = "") -> bump.Answer: + return bump.Answer(verdict, "p", "1.0.0", "2.0.0", detail=detail) + + +def test_what_a_verdict_asks_of_a_person() -> None: + """Ordered by what was established, not by a severity nobody here has read.""" + assert bump.needs_of(_report("a", _answer(bump.Verdict.ALREADY_RED))) is ( + bump.Needs.FIX_YOUR_SUITE + ) + assert bump.needs_of(_report("b", _answer(bump.Verdict.CLEAN))) is bump.Needs.JUST_TAKE_IT + assert bump.needs_of(_report("c", _answer(bump.Verdict.BREAKS, "FAILED x"))) is ( + bump.Needs.NEEDS_WORK + ) + assert bump.needs_of(_report("d", _answer(bump.Verdict.CANNOT_MOVE))) is bump.Needs.BLOCKED + + +def test_a_candidate_that_broke_does_not_outrank_one_that_later_passed() -> None: + """Trying three versions and settling on the third is `ready to take`, not `needs work`. + + Reintroducing this put every package that ever saw a red candidate into the section a person + has to work — which on lodash's seven advisories is most of them, and all of them wrongly. + """ + settled = _report( + "lodash", _answer(bump.Verdict.BREAKS, "FAILED a"), _answer(bump.Verdict.CLEAN) + ) + assert bump.needs_of(settled) is bump.Needs.JUST_TAKE_IT + + +def test_the_queue_is_ordered_worst_first_and_easiest_within_that() -> None: + """**The answer to the complaint Renovate cannot answer.** + + *"Here is every update, you decide"* is noise because nothing in it is ranked, and ranking + needs knowing what each one does. Within `needs work`, fewest broken tests first: the two-test + upgrade is the one somebody closes this afternoon, and burying it under a twelve-test one + hides the achievable behind the daunting. + """ + reports = [ + _report("easy", _answer(bump.Verdict.BREAKS, "FAILED a\nFAILED b")), + _report("clean", _answer(bump.Verdict.CLEAN)), + _report("stuck", _answer(bump.Verdict.CANNOT_MOVE, "constrained")), + _report("hard", _answer(bump.Verdict.BREAKS, "\n".join(f"FAILED {i}" for i in range(12)))), + _report("red", _answer(bump.Verdict.ALREADY_RED)), + ] + + assert [r.package for r in bump.ranked(reports)] == [ + "red", # nothing else can be decided until this is fixed + "easy", # needs a person, and is the cheapest of those + "hard", + "stuck", # nothing to try + "clean", # nothing to decide + ] + + +def test_the_summary_is_the_sentence_that_replaces_the_queue() -> None: + reports = [ + _report("a", _answer(bump.Verdict.CLEAN)), + _report("b", _answer(bump.Verdict.CLEAN)), + _report("c", _answer(bump.Verdict.BREAKS, "FAILED x")), + _report("d", _answer(bump.Verdict.WILL_NOT_INSTALL)), + ] + + counted = bump.summary(reports) + + assert counted[bump.Needs.JUST_TAKE_IT] == 2 + assert counted[bump.Needs.NEEDS_WORK] == 1 + assert counted[bump.Needs.BLOCKED] == 1 + assert counted[bump.Needs.FIX_YOUR_SUITE] == 0 + # Every bucket is present even at zero: a reader must be able to tell "none" from "not counted". + assert set(counted) == set(bump.Needs) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..cc91deb --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,627 @@ +"""What the project pinned, and what OSV says about it. Item 172, DR-0016. + +**The suite never opens a socket.** httpx2's `MockTransport` serves the recorded shapes, exactly as +`test_forge_code.py` does — an OSV client tested against the real service would be a test whose +result depends on somebody else's database changing. + +**Lockfiles rather than declarations**, because a declaration is a range and a range is not a fact. +Two of the four readers below are checked against files that actually exist in this repository +rather than only against fixtures written for the test, which is the same rule `test_propose.py` +runs on: a reader tested only against its own fixtures is a reader tested against itself. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +import io +from collections.abc import Callable +from pathlib import Path + +import httpx2 + +from hullwork import dependencies, osv +from hullwork.manifest import Manifest, parse_manifest + +ROOT = Path(__file__).resolve().parent.parent + +PACKAGE_LOCK = """ +{ + "name": "thing", + "lockfileVersion": 3, + "packages": { + "": {"name": "thing", "version": "1.0.0"}, + "node_modules/lodash": {"version": "4.17.20", "resolved": "https://registry.npmjs.org/x"}, + "node_modules/@scope/pkg": {"version": "2.1.0"}, + "node_modules/no-version": {"resolved": "https://registry.npmjs.org/y"} + } +} +""" + +POETRY_LOCK = """ +[[package]] +name = "jinja2" +version = "2.4.1" +description = "A templating engine" + +[[package]] +name = "requests" +version = "2.31.0" +""" + +REQUIREMENTS = """ +# a comment +jinja2==2.4.1 +requests>=2.0 ; not a pin +django ~= 4.2 ; not a pin either +lodash==4.17.20 + +-e . +""" + + +def _read(files: dict[str, str]) -> Callable[[str], str | None]: + def read(path: str) -> str | None: + return files.get(path) + + return read + + +# --- the readers -------------------------------------------------------------------------- + + +def test_package_lock_gives_up_its_packages_without_the_root_entry() -> None: + """The `""` key is the project itself, not a dependency of it. + + Including it would have Hullwork ask OSV about the repository being scanned, which is both + wrong and slightly embarrassing. + """ + found = dependencies.read_lockfiles( + ["package-lock.json"], _read({"package-lock.json": PACKAGE_LOCK}) + ) + + assert [(d.name, d.version) for d in found] == [ + ("lodash", "4.17.20"), + ("@scope/pkg", "2.1.0"), + ] + assert {d.ecosystem for d in found} == {"npm"} + assert all(d.source == "package-lock.json" for d in found) + + +def test_a_package_lock_entry_with_no_version_is_not_a_pin() -> None: + """It appears in the file and pins nothing, so it is not something to ask about.""" + found = dependencies.read_lockfiles( + ["package-lock.json"], _read({"package-lock.json": PACKAGE_LOCK}) + ) + assert "no-version" not in {d.name for d in found} + + +def test_poetry_and_uv_are_the_same_two_keys() -> None: + """Both are TOML with `[[package]]`, `name` and `version`. One reader, not two.""" + found = dependencies.read_lockfiles(["poetry.lock"], _read({"poetry.lock": POETRY_LOCK})) + + assert [(d.name, d.version) for d in found] == [("jinja2", "2.4.1"), ("requests", "2.31.0")] + assert {d.ecosystem for d in found} == {"PyPI"} + + +def test_this_repositorys_own_uv_lock_reads(tmp_path: Path) -> None: + """**The measurement that stops this being tested against itself.** + + Not a fixture: `uv.lock` as this repository actually pins it. If the format moves under us, + this is what says so. + """ + del tmp_path + text = (ROOT / "uv.lock").read_text(encoding="utf-8") + found = dependencies.read_lockfiles(["uv.lock"], _read({"uv.lock": text})) + + names = {d.name for d in found} + assert {"alembic", "httpx2", "pydantic"} <= names + assert all(d.version for d in found), "a package with no version is not a pin" + assert all(d.ecosystem == "PyPI" for d in found) + + +def test_requirements_counts_what_it_could_not_pin() -> None: + """**The honest half of the weakest reader.** + + A line that is not `==` is not a pin and is skipped — but reporting four packages from a file + with six requirement lines, without saying so, would understate the answer silently. + """ + found = dependencies.read_lockfiles( + ["requirements.txt"], _read({"requirements.txt": REQUIREMENTS}) + ) + + assert [(d.name, d.version) for d in found] == [ + ("jinja2", "2.4.1"), + ("lodash", "4.17.20"), + ] + assert dependencies.unpinned(REQUIREMENTS) == 2, "requests and django are ranges, not pins" + + +def test_a_checkout_with_no_lockfile_is_told_what_was_looked_for() -> None: + """An empty list reads as "you have no dependencies", which is a different claim.""" + assert dependencies.read_lockfiles(["README.md"], _read({"README.md": "#"})) == [] + for name in ("package-lock.json", "uv.lock", "poetry.lock", "requirements.txt"): + assert name in dependencies.WHAT_IS_LOOKED_FOR + + +# --- OSV ---------------------------------------------------------------------------------- + + +def _osv(handler: Callable[[httpx2.Request], httpx2.Response]) -> osv.Osv: + return osv.Osv(transport=httpx2.MockTransport(handler)) + + +def test_it_batches_the_query_and_asks_for_detail_only_on_what_came_back() -> None: + """One batch for N packages, then one detail call per id — not per package.""" + calls: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + calls.append(str(request.url)) + if request.url.path.endswith("/querybatch"): + return httpx2.Response(200, json={"results": [{"vulns": [{"id": "GHSA-x"}]}, {}]}) + return httpx2.Response(200, json={ + "id": "GHSA-x", + "summary": "template injection", + "affected": [{ + "package": {"name": "jinja2", "ecosystem": "PyPI"}, + "ranges": [{"events": [{"introduced": "0"}, {"fixed": "2.11.3"}]}], + }], + }) + + deps = [ + dependencies.Dependency("PyPI", "jinja2", "2.4.1", "poetry.lock"), + dependencies.Dependency("PyPI", "requests", "2.31.0", "poetry.lock"), + ] + found = _osv(handler).affected(deps) + + assert len(calls) == 2, "one batch and one detail, never one call per package" + assert [f.dependency.name for f in found] == ["jinja2"] + assert found[0].advisories[0].fixed == ("2.11.3",) + + +def test_a_vulnerability_with_no_published_fix_says_so() -> None: + """There is no bump to attempt, and proposing the next release and hoping is not an answer.""" + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path.endswith("/querybatch"): + return httpx2.Response(200, json={"results": [{"vulns": [{"id": "GHSA-y"}]}]}) + return httpx2.Response(200, json={ + "id": "GHSA-y", + "summary": "unfixed", + "affected": [{ + "package": {"name": "lodash", "ecosystem": "npm"}, + "ranges": [{"events": [{"introduced": "0"}]}], + }], + }) + + deps = [dependencies.Dependency("npm", "lodash", "4.17.20", "package-lock.json")] + found = _osv(handler).affected(deps) + + assert found[0].advisories[0].fixed == () + assert found[0].advisories[0].has_a_fix is False + + +def test_several_fixed_versions_are_all_reported_and_none_is_chosen() -> None: + """**Where honesty is cheap to lose.** + + Choosing would mean comparing versions across two ecosystems' ordering rules, and a wrong + choice is a bump that does not fix what it claims to. All of them, and a person decides. + """ + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path.endswith("/querybatch"): + return httpx2.Response(200, json={"results": [{"vulns": [{"id": "GHSA-z"}]}]}) + return httpx2.Response(200, json={ + "id": "GHSA-z", + "summary": "two branches", + "affected": [{ + "package": {"name": "jinja2", "ecosystem": "PyPI"}, + "ranges": [ + {"events": [{"introduced": "0"}, {"fixed": "2.11.3"}]}, + {"events": [{"introduced": "3.0"}, {"fixed": "3.1.3"}]}, + ], + }], + }) + + deps = [dependencies.Dependency("PyPI", "jinja2", "2.4.1", "poetry.lock")] + found = _osv(handler).affected(deps) + + assert found[0].advisories[0].fixed == ("2.11.3", "3.1.3") + assert found[0].advisories[0].has_a_fix is True + + +def test_an_advisory_about_another_package_does_not_become_this_ones_fix() -> None: + """One advisory can name several packages; only the matching one decides this bump. + + Reintroducing this defect made a jinja2 finding claim npm's fixing version, which is the kind + of wrong answer that looks entirely plausible in a report. + """ + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path.endswith("/querybatch"): + return httpx2.Response(200, json={"results": [{"vulns": [{"id": "GHSA-multi"}]}]}) + return httpx2.Response(200, json={ + "id": "GHSA-multi", + "summary": "affects two ecosystems", + "affected": [ + {"package": {"name": "lodash", "ecosystem": "npm"}, + "ranges": [{"events": [{"introduced": "0"}, {"fixed": "9.9.9"}]}]}, + {"package": {"name": "jinja2", "ecosystem": "PyPI"}, + "ranges": [{"events": [{"introduced": "0"}, {"fixed": "2.11.3"}]}]}, + ], + }) + + deps = [dependencies.Dependency("PyPI", "jinja2", "2.4.1", "poetry.lock")] + found = _osv(handler).affected(deps) + + assert found[0].advisories[0].fixed == ("2.11.3",) + + +def test_nothing_affected_asks_for_no_detail_at_all() -> None: + """A clean project costs one request, not one plus zero-length follow-ups.""" + calls: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + calls.append(str(request.url)) + return httpx2.Response(200, json={"results": [{}, {}]}) + + deps = [ + dependencies.Dependency("npm", "a", "1.0.0", "package-lock.json"), + dependencies.Dependency("npm", "b", "1.0.0", "package-lock.json"), + ] + assert _osv(handler).affected(deps) == [] + assert len(calls) == 1 + + +def test_one_advisory_shared_by_two_packages_is_fetched_once() -> None: + """**The test the call-counting one above did not make redundant.** + + That test has a single affected package, so the id cache is never asked the same question + twice and removing it left every assertion green. A lock file with two packages under the + same advisory is the case that pays for the cache — and it is the common one, since a flaw in + a library is published once and hits everything that pins it. + """ + calls: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + calls.append(str(request.url)) + if request.url.path.endswith("/querybatch"): + return httpx2.Response(200, json={ + "results": [{"vulns": [{"id": "GHSA-same"}]}, {"vulns": [{"id": "GHSA-same"}]}], + }) + return httpx2.Response(200, json={ + "id": "GHSA-same", + "summary": "one flaw, two pins", + "affected": [ + {"package": {"name": "a", "ecosystem": "npm"}, + "ranges": [{"events": [{"introduced": "0"}, {"fixed": "1.1.0"}]}]}, + {"package": {"name": "b", "ecosystem": "npm"}, + "ranges": [{"events": [{"introduced": "0"}, {"fixed": "2.2.0"}]}]}, + ], + }) + + deps = [ + dependencies.Dependency("npm", "a", "1.0.0", "package-lock.json"), + dependencies.Dependency("npm", "b", "2.0.0", "package-lock.json"), + ] + found = _osv(handler).affected(deps) + + detail_calls = [c for c in calls if "/vulns/" in c] + assert len(detail_calls) == 1, "the same advisory must not be fetched once per package" + # And each package still gets its own fixing version out of the one document. + assert found[0].advisories[0].fixed == ("1.1.0",) + assert found[1].advisories[0].fixed == ("2.2.0",) + + +# --- the requirements files nobody read (item 180) -------------------------------------------- + + +def test_the_layouts_python_projects_actually_use_are_all_read() -> None: + """Item 180, found by running `deps` against this repository. + + `requirements/build.txt` is tracked here, pins three packages, and was read as none of them — + with a confident count printed above the silence. Item 172 matched the exact basename, and its + own reasoning for matching on a name at all condemns that: *"a monorepo pins per package, and + only reading the root would report a fraction of the truth as the whole."* + """ + files = { + "requirements.txt": "a==1.0\n", + "requirements/base.txt": "b==2.0\n", + "requirements/prod.txt": "c==3.0\n", + "requirements-dev.txt": "d==4.0\n", + "dev-requirements.txt": "e==5.0\n", + "backend/requirements/test.txt": "f==6.0\n", + } + + found = dependencies.read_lockfiles(list(files), lambda p: files.get(p)) + + assert {d.name for d in found} == {"a", "b", "c", "d", "e", "f"} + # And each one says which file said so, because a report that cannot is a report nobody can act + # on — a project can pin the same name in two files at two versions. + assert {d.source for d in found} == set(files) + + +def test_a_text_file_that_is_not_a_requirements_list_is_not_read_at_all() -> None: + """The bound on item 180's widening, and the first version of this test did not test it. + + **It used prose containing no line that looks like a pin**, so it passed whether the matcher + was careful or `^.*\\.txt$` — verified by making it exactly that and watching nothing go red. + A changelog is the realistic hazard and it *does* carry pin-shaped lines at the start of a + line, which is where `_PINNED` looks. + + What it would cost is not a wrong count. `--verify` would try to rewrite that line, and + `--open` would offer somebody a pull request editing their release notes. + """ + changelog = ( + "2.10.1 — 2026-01-01\n" + "===================\n" + "jinja2==2.10.1 is now the minimum supported version.\n" + "requests==2.31.0 was dropped from the extras.\n" + ) + + read = dependencies.read_lockfiles(["docs/notes.txt", "CHANGELOG.txt"], lambda _p: changelog) + + assert read == [] + assert not dependencies.is_requirements("CHANGELOG.txt") + assert not dependencies.is_requirements("docs/notes.txt") + # And the near misses, which are the ones a looser pattern would take. + assert not dependencies.is_requirements("install-requirements-guide.txt") + assert not dependencies.is_requirements("requirements.md") + + # The same text under a name that *is* a requirements file is read, which is what makes the + # assertions above about the name rather than about the content. + assert len(dependencies.read_lockfiles(["requirements/prod.txt"], lambda _p: changelog)) == 2 + + +def test_one_package_pinned_twice_in_two_files_is_two_facts() -> None: + """A real thing, and merging them would hide the disagreement rather than report it.""" + files = { + "requirements/base.txt": "jinja2==2.4.1\n", + "requirements/dev.txt": "jinja2==3.1.4\n", + } + + found = dependencies.read_lockfiles(list(files), lambda p: files.get(p)) + + assert sorted((d.version, d.source) for d in found) == [ + ("2.4.1", "requirements/base.txt"), + ("3.1.4", "requirements/dev.txt"), + ] + + +def test_this_repositorys_own_requirements_directory_reads() -> None: + """The falsifiable gate, as a test. Item 180. + + Against the real file rather than a fixture, for `test_this_repositorys_own_uv_lock_reads`'s + reason: a reader tested only against its own fixtures is a reader tested against itself. This + one is also the exact file that produced the finding. + """ + path = ROOT / "requirements" / "build.txt" + found = dependencies.read_lockfiles( + ["requirements/build.txt"], lambda _p: path.read_text(encoding="utf-8") + ) + + assert len(found) >= 3 + assert all(d.source == "requirements/build.txt" for d in found) + assert "build" in {d.name for d in found} + + +# --- the build context a verification gets (item 182) ----------------------------------------- + + +def test_verify_hands_the_source_to_a_build_that_reads_it( + tmp_path: Path, monkeypatch: object +) -> None: + """Item 113's fix, on the path that never inherited it. Found on the first third-party tree. + + The build context holds the **declared dependency files and never the source**, and three + ordinary installers read the source anyway: a `requirements.txt` that begins `-e .`, a `Gemfile` + that says `gemspec`, and `mvn test`. Item 113 measured all three and fixed the attempt path; + `deps --verify` was written afterwards and passed `source=None` unconditionally. + + Measured on `encode/httpx`, whose first requirement is `-e .[brotli,cli,http2,socks,zstd]`: + + ERROR: file:///work does not appear to be a Python project: + neither 'setup.py' nor 'pyproject.toml' found. + + Reported to the reader as *your own environment does not build*, which was true of what we built + and false of the project. Ruby, Java and PHP are all on the roadmap as stacks whose attempts + work, and every one of them reaches this the same way. + + Driven through `image.build` rather than through Docker: what is under test is **what the + verification asks for**, and a daemon would prove the same call more slowly. + """ + import pytest + + from hullwork import cli + from hullwork.manifest import parse_manifest + + asked: list[dict[str, object]] = [] + + class Built: + tag = "img:1" + + def watch(runtime: object, files: object, engine: object, **kwargs: object) -> Built: + asked.append(kwargs) + return Built() + + (tmp_path / "requirements.txt").write_text("-e .\njinja2==2.4.1\n") + (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\nversion='1'\n") + manifest = parse_manifest( + "project: p\ngit: {provider: github, repo: o/r}\n" + 'tests: "pytest"\ntest_path: tests\n' + "runtime: {base: python-3.12, install: pip, dependencies: [requirements.txt], " + "install_needs_source: true}\n" + ) + from hullwork.sandbox import image as image_module + + monkeypatch.setattr(image_module, "build", watch) # type: ignore[attr-defined] + # The first build is the baseline and is the only one this needs: it either carries the source + # or it does not, and everything after it is the same call. + with pytest.raises(Exception): # noqa: B017 - it stops at the sandbox, after the build + cli._verify_one( + tmp_path, ["requirements.txt"], lambda p: (tmp_path / p).read_text(), + manifest, + dependencies.Dependency("PyPI", "jinja2", "2.4.1", "requirements.txt"), + ["2.10.1"], io.StringIO(), + ) + + assert asked, "no image was built at all" + assert asked[0]["source"] is not None, "the build got no source to install from" + assert asked[0]["source_ref"] is not None, ( + "an image whose source is not in its tag is an image reused across commits" + ) + + +def test_a_project_whose_install_does_not_read_the_source_still_gets_none( + tmp_path: Path, monkeypatch: object +) -> None: + """The cheap path stays cheap: the tag does not move and the image is reused between runs.""" + import pytest + + from hullwork import cli + from hullwork.manifest import parse_manifest + from hullwork.sandbox import image as image_module + + asked: list[dict[str, object]] = [] + + class Built: + tag = "img:1" + + def watch(runtime: object, files: object, engine: object, **kwargs: object) -> Built: + asked.append(kwargs) + return Built() + + (tmp_path / "requirements.txt").write_text("jinja2==2.4.1\n") + manifest = parse_manifest( + "project: p\ngit: {provider: github, repo: o/r}\n" + 'tests: "pytest"\ntest_path: tests\n' + "runtime: {base: python-3.12, install: pip, dependencies: [requirements.txt]}\n" + ) + monkeypatch.setattr(image_module, "build", watch) # type: ignore[attr-defined] + with pytest.raises(Exception): # noqa: B017 + cli._verify_one( + tmp_path, ["requirements.txt"], lambda p: (tmp_path / p).read_text(), + manifest, + dependencies.Dependency("PyPI", "jinja2", "2.4.1", "requirements.txt"), + ["2.10.1"], io.StringIO(), + ) + + assert asked and asked[0]["source"] is None + assert asked[0]["source_ref"] is None + + +# --- what cannot be verified at all, said before anything is built (item 182) ------------------ + + +def _manifest(declared: str) -> Manifest: + return parse_manifest( + "project: p\ngit: {provider: github, repo: o/r}\n" + 'tests: "pytest"\ntest_path: tests\n' + f"runtime: {{base: python-3.12, install: pip, dependencies: {declared}}}\n" + ) + + +def test_a_pin_in_a_file_the_image_never_installs_cannot_be_verified() -> None: + """**The false artefact this whole product exists to prevent, produced by the product.** + + `pallets/flask` pins four of its five advisory-carrying packages in + `examples/celery/requirements.txt`, which its image is not built from. Rewriting a version + there changes no byte the build reads: `dependency_digest` does not move, the image is reused, + and the suite passes exactly as it passed before — so the verdict is `clean` and the queue says + *ready to take*. + + Measured against a real daemon on 2026-08-09, on a tree declaring `requirements.txt` and + carrying `extras/requirements.txt`: + + [ready to take] jinja2 2.4.1 → 2.10.1 + $ docker run --rm python -c "import jinja2" + ModuleNotFoundError: No module named 'jinja2' + + A package **not installed in the environment its suite ran in**, offered as ready to merge — + and with item 178's `--open`, as a pull request. Item 174 found the same false verdict by the + other route, where the box was reused; this one needs no box at all. + """ + from hullwork import cli + + outside = dependencies.Dependency("PyPI", "jinja2", "2.4.1", "extras/requirements.txt") + + refusal = cli._cannot_be_verified(outside, _manifest("[requirements.txt]"), ["2.10.1"]) + + assert refusal is not None + assert "not one of the files your image is built from" in refusal + assert "requirements.txt" in refusal + + +def test_a_pin_the_image_does_install_is_verified_as_before() -> None: + """The refusal must not swallow the ordinary case, which is every project with one file.""" + from hullwork import cli + + inside = dependencies.Dependency("PyPI", "jinja2", "2.4.1", "requirements.txt") + + assert cli._cannot_be_verified(inside, _manifest("[requirements.txt]"), ["2.10.1"]) is None + + +def test_a_project_that_installs_nothing_cannot_have_an_upgrade_measured() -> None: + """**The worse half of the same finding, and `install: none` is the default value.** + + The generated Dockerfile copies no dependency file and runs no installer when `install` is + `none`, so the environment is `runtime.base` exactly as it comes. DR-0007 makes *the project + brings its own image* the primary path, so this is most projects rather than an edge. + + Measured on 2026-08-09 against a base image carrying `jinja2 3.0.0`, on a checkout pinning + `jinja2==2.4.1`: + + [ready to take] jinja2 2.4.1 → 2.10.1 + $ docker run --rm python -c "import jinja2; print(__version__)" + 3.0.0 + 3.0.0 + + **Neither version in the claim was ever installed.** The verdict said the suite passed before + the change and after it, which was true and was about nothing. + """ + from hullwork import cli + from hullwork.manifest import parse_manifest + + own_image = parse_manifest( + "project: p\ngit: {provider: github, repo: o/r}\n" + 'tests: "pytest"\ntest_path: tests\n' + "runtime: {base: python-3.12, install: none, dependencies: []}\n" + ) + anywhere = dependencies.Dependency("PyPI", "jinja2", "2.4.1", "requirements.txt") + + refusal = cli._cannot_be_verified(anywhere, own_image, ["2.10.1"]) + + assert refusal is not None + assert "install: none" in refusal + assert "rebuilding it" in refusal, "it has to say what a person does instead" + + +def test_a_refusal_is_counted_rather_than_only_printed() -> None: + """"I could not verify this" is a first-class answer, so it has to be in the tally. + + **Driven through `_verify_upgrades`, because the first version of this was not.** That one + asserted `needs_of` and `summary` over a hand-built report — both already true — so it passed + with the `reports.append` deleted. What it claims to check is that the refusal *reaches* the + queue, and only the function that builds the queue can say. + + A refusal that only reached the terminal left the summary saying `0 blocked` for a run that + could verify none of them, and that count is the one number in this command a person acts on. + No Docker: every refusal here is answered before anything is built. + """ + from hullwork import bump, cli, osv + + outside = dependencies.Dependency("PyPI", "jinja2", "2.4.1", "extras/requirements.txt") + findings = [ + osv.Finding( + dependency=outside, + advisories=(osv.Advisory(id="GHSA-x", summary="", fixed=("2.10.1",)),), + ) + ] + printed = io.StringIO() + + reports = cli._verify_upgrades( + Path("/nowhere"), ["extras/requirements.txt"], lambda _p: None, + _manifest("[requirements.txt]"), findings, printed, + ) + + assert len(reports) == 1, "the refusal never reached the queue" + assert bump.needs_of(reports[0]) is bump.Needs.BLOCKED + assert bump.summary(reports)[bump.Needs.BLOCKED] == 1 + assert "not one of the files your image is built from" in printed.getvalue() diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index efaed66..9a16f66 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -449,6 +449,58 @@ def test_a_deleted_conftest_is_restored(session: Session, item: Item, tmp_path: assert "tests/conftest.py" in verdict.detail +def test_a_conftest_the_fix_invented_is_caught_too( + session: Session, item: Item, tmp_path: Path +) -> None: + """The hole in item 046, found while building item 179 and measured on this sequence. + + **The guard iterated the before-image**, so it restored configuration that was *edited* and + never saw configuration that was *invented*. A project with no `conftest.py` — which is most of + them — could have one written by the fix phase: absent from `pristine`, never restored, + `restored` empty so no second gate ran, and the attempt published as `pr-open` with the file + that decided the gate inside its own diff. Verified by removing `created_test_config` from + `_restore_infrastructure`, at which point this publishes. + + The third verb, after edited and deleted, and the one nothing here expressed: **created**. + """ + # Deliberately no `_with_conftest`: the premise is a project that has none. + verdict, _, _ = _go( + session, item, tmp_path, + # baseline green, red gate red, green gate green (nothing collected), restored gate red. + script={"pytest#1": 1, "pytest#3": 1, REPRO: 0, FIX: 0}, + writes={REPRO: GOOD_TEST, FIX: {"conftest.py": "collect_ignore_glob = ['*']\n"}}, + ) + + assert verdict.outcome is AttemptOutcome.FAILED + assert verdict.phase is AttemptPhase.GREEN_GATE_RESTORED + assert "conftest.py" in verdict.detail + assert "conftest.py" not in verdict.changes.written + assert not (tmp_path / "conftest.py").exists(), "there is nothing to put it back to" + + +def test_a_test_the_fix_invented_is_still_welcome( + session: Session, item: Item, tmp_path: Path +) -> None: + """The distinction the narrower predicate exists to keep. + + A new file under `tests/` is a test and a fix that adds one is welcome; a new `conftest.py` is + configuration and there is no legitimate version of a fix phase inventing one. Using + `is_test_infrastructure` for the created case would have deleted both. + """ + verdict, _, _ = _go( + session, item, tmp_path, + script={"pytest#1": 1, REPRO: 0, FIX: 0}, + writes={ + REPRO: GOOD_TEST, + FIX: {"src.py": "x = 2\n", "tests/test_new.py": "def test_new():\n pass\n"}, + }, + ) + + assert verdict.outcome is AttemptOutcome.PR_OPEN + assert verdict.restored == "" + assert "tests/test_new.py" in verdict.changes.written + + # --- the agent is told what its test will be judged by (item 064) --------------------------------- diff --git a/tests/test_forge_code.py b/tests/test_forge_code.py index 3aeff02..8876c3d 100644 --- a/tests/test_forge_code.py +++ b/tests/test_forge_code.py @@ -238,9 +238,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: def test_the_sign_off_trailer_is_never_requested() -> None: """The API will add it, and that is exactly why we must not ask. - `CONTRIBUTING.md` and the worker contract both say the DCO sign-off is a human act, done at the - merge gate. A machine that can emit the trailer can certify provenance it has no standing to - certify. + `CONTRIBUTING.md` says the DCO sign-off is a human act, done at the merge gate. A machine + that can emit the trailer can certify provenance it has no standing to certify. """ seen: dict[str, Any] = {} diff --git a/tests/test_propose.py b/tests/test_propose.py index e5d0ced..b3e755f 100644 --- a/tests/test_propose.py +++ b/tests/test_propose.py @@ -19,6 +19,7 @@ from __future__ import annotations +import subprocess from pathlib import Path import pytest @@ -308,3 +309,199 @@ def test_a_proposal_never_names_an_agent() -> None: parse_manifest(rendered) except ManifestError as exc: # pragma: no cover - a failure here is the point pytest.fail(f"the proposal for {text[:24]!r} is not a manifest: {exc}") + + +# --- Which forge holds this. Item 171. ------------------------------------------------- +# +# The defect: `render` emitted `provider: forgejo` as a constant, uncommented, so it read +# as observed while no code path could produce any other value. Found by running the +# command against a checkout hosted on GitHub and reading the two lines it printed. + + +def test_the_remote_host_decides_when_it_is_one_that_can_be_recognised() -> None: + """`github.com` and `gitlab.com` are the only hosts that name themselves. + + Everything else is self-hosted and unresolvable without asking it, which `propose` + may never do — it reaches nothing and needs no credential. + """ + assert propose.forge_for(".github/workflows/ci.yml", "github.com").provider == "github" + assert propose.forge_for(".gitlab-ci.yml", "gitlab.com").provider == "gitlab" + + +def test_the_github_workflows_directory_is_not_evidence_of_github() -> None: + """**The finding that stops this being a one-line change.** + + Forgejo Actions and Gitea Actions both read `.github/workflows/`, and this repository's + own deployment is the proof — those workflows run on a Forgejo instance. Reading that + directory as GitHub would be wrong for exactly the self-hosted projects this is for. + + This test exists to fail if somebody later "improves" the mapping. + """ + guess = propose.forge_for(".github/workflows/ci.yml", "git.example.com") + assert guess.evidence is None, "that directory settles nothing on an unknown host" + assert guess.provider == propose.PROVIDER_WHEN_UNDECIDED + + +def test_the_ci_location_decides_when_the_host_cannot() -> None: + """A self-hosted host says nothing; the three unambiguous directories say plenty.""" + for source, expected in ( + (".forgejo/workflows/ci.yml", "forgejo"), + (".gitea/workflows/ci.yml", "gitea"), + (".gitlab-ci.yml", "gitlab"), + ): + guess = propose.forge_for(source, "git.example.com") + assert guess.provider == expected, source + assert guess.evidence is not None + + +def test_the_host_outranks_the_ci_location() -> None: + """Where the repository lives beats which runner reads its workflows. + + A GitHub repository whose workflows sit in `.forgejo/workflows/` is a mirror, and the + coordinate a manifest needs is the one that answers requests. + """ + assert propose.forge_for(".forgejo/workflows/ci.yml", "github.com").provider == "github" + + +def test_a_remote_url_gives_up_its_host_in_both_spellings() -> None: + """`https://` and the `git@host:owner/name` form, plus what is not a URL at all.""" + assert propose.host_of_remote("https://github.com/owner/repo.git") == "github.com" + assert propose.host_of_remote("git@github.com:owner/repo.git") == "github.com" + assert propose.host_of_remote("ssh://git@forge.example.com/o/r") == "forge.example.com" + assert propose.host_of_remote(None) is None + assert propose.host_of_remote("not a url") is None + + +def test_an_undecided_provider_says_so_instead_of_looking_read() -> None: + """The whole point of the item: a default must not wear the costume of a reading. + + `render`'s contract is that uncommented means observed. When nothing decided, the line + still has to carry a value — `git.provider` is required and an unparseable proposal is + worse — so it carries the default *and says which two things would have settled it*. + """ + rendered = propose.render(propose.read("a/b", ".github/workflows/ci.yml", WITH_A_CONTAINER)) + lines = rendered.splitlines() + git_at = next(i for i, line in enumerate(lines) if line.startswith("git:")) + + assert "provider: forgejo" in lines[git_at] + said_it = "\n".join(lines[max(0, git_at - 3) : git_at]) + assert "default, not a reading" in said_it, "an undecided value has to admit that it is one" + assert ".github/workflows/" in said_it, "and say why that directory settled nothing" + parse_manifest(rendered) # and it still parses, which is why the value stays + + +def test_a_decided_provider_does_not_apologise_for_itself() -> None: + """The mirror of the test above: an observation must not read as a guess.""" + proposal = propose.read("a/b", ".github/workflows/ci.yml", WITH_A_CONTAINER) + proposal.remote_host = "github.com" + rendered = propose.render(proposal) + + assert "provider: github" in rendered + git_line = next(line for line in rendered.splitlines() if line.startswith("git:")) + assert "default" not in git_line + parse_manifest(rendered) + + +def test_the_header_and_the_provider_can_never_contradict_each_other() -> None: + """The defect as a reader saw it: a header naming one forge's file, then another's name. + + Asserted for the three locations that decide, on a host that decides nothing — which is + where the contradiction was reachable. + """ + for source, text, expected in ( + (".forgejo/workflows/ci.yml", WITH_A_CONTAINER, "forgejo"), + (".gitea/workflows/ci.yml", WITH_A_CONTAINER, "gitea"), + (".gitlab-ci.yml", GITLAB, "gitlab"), + ): + proposal = propose.read("a/b", source, text) + proposal.remote_host = "git.example.com" + rendered = propose.render(proposal) + header = rendered.splitlines()[0] + assert source in header + assert f"provider: {expected}" in rendered, f"{header} then a different forge" + + +def test_the_command_itself_carries_the_host_to_the_proposal(tmp_path: Path) -> None: + """**The test the other eight did not make redundant**, and the reason it exists. + + Every assertion above exercises pure functions. Deleting the one line in `cli.py` that + reads the remote and hands its host to the proposal left all of them green — so the whole + repair would have been reachable for deletion without a single test noticing, which is the + shape of defect item 116 found (a test that passes with its own subject removed). + + This drives the command against a real checkout with a real `origin`, which is how the + defect was found in the first place. + """ + from hullwork.cli import propose_from_local_ci + + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "ci.yml").write_text(WITH_A_CONTAINER, encoding="utf-8") + + for argv in ( + ["init", "-q"], + ["add", "-A"], + ["remote", "add", "origin", "https://github.com/owner/thing.git"], + ): + done = subprocess.run( # noqa: S603 + ["git", "-C", str(tmp_path), *argv], # noqa: S607 + capture_output=True, text=True, check=False, + ) + assert done.returncode == 0, done.stderr + + rendered = propose_from_local_ci(tmp_path) + assert rendered is not None + assert "git: {provider: github, repo: owner/thing}" in rendered + parse_manifest(rendered) + + +def test_a_proposal_with_no_installer_says_what_that_costs() -> None: + """Item 185. Every field this reader cannot fill carries a comment saying what is missing; + `install` carried none, and its absence is the one that is invisible. + + **Because the manifest it produces parses and builds perfectly.** What it cannot do is measure a + dependency upgrade: with no installer the image is the base exactly as it comes, so rewriting a + pinned version changes nothing the suite runs against. Measured on 2026-08-09 (item 182) before + this comment existed — a checkout pinning `jinja2==2.4.1`, a base image carrying 3.0.0, and a + verdict reading *your suite passed before this change and passes after it*, about a version that + was never installed. + """ + proposal = propose.Proposal(repo="o/r", source="ci.yml", base="python-3.12", tests="pytest") + + text = propose.render(proposal) + + assert "cannot be **measured**" in text + assert "changes nothing your suite would run against" in text + # **And what to do about it, which item 188 added.** Saying only the cost sent a reader with an + # image of their own away from a feature that serves them: `base` takes any image and `install` + # takes their own command, so the answer is one layer on top of what they already named — not a + # rebuild from scratch, and not a stack anybody has to add. + assert "keep this base and add two lines" in text + assert "one layer on top of the image you named" in text + # The command is not named: it is not in the published image, and naming something a reader + # cannot run invites them to type it and be told it does not exist. + assert "hullwork deps" not in text + + +def test_a_proposal_that_names_an_installer_says_nothing_of_the_kind() -> None: + """The note is about an absence. A manifest that can measure must not carry a caveat it earned + nothing of — a warning printed for everybody is a warning nobody reads.""" + proposal = propose.Proposal( + repo="o/r", source="ci.yml", base="python-3.12", tests="pytest", + install="pip", dependencies=("requirements.txt",), + ) + + text = propose.render(proposal) + + assert "cannot be **measured**" not in text + assert "install: 'pip'" in text + + +def test_a_proposal_with_no_base_does_not_lecture_about_installers() -> None: + """Without a base the whole `runtime` block is commented out and refused anyway (item 111). + Adding a second paragraph about a third field there is noise on top of a refusal.""" + proposal = propose.Proposal(repo="o/r", source="ci.yml", tests="pytest") + + text = propose.render(proposal) + + assert "cannot be **measured**" not in text diff --git a/tests/test_refit.py b/tests/test_refit.py new file mode 100644 index 0000000..d423bc0 --- /dev/null +++ b/tests/test_refit.py @@ -0,0 +1,973 @@ +"""The fix for the ones that break. Item 179, DR-0018 step 4. + +Driven with a fake sandbox, like `test_dispatch`, because what is under test is the *sequence and +its guards* rather than Docker — and the guards are the whole of this item. The container is proved +by effect elsewhere; the real Docker run for this sequence is recorded in the item. + +**The defect every test here circles.** Reverting the dependency passes every gate: red before, +green after, and the upgrade gone. It is the most plausible-looking false artefact this product +could emit, so the dependency files are read-only to the fix phase and the version is read back out +of the tree after the green gate. Both, because they fail differently. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +import argparse +import io +import json +from collections.abc import Callable +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from sqlalchemy.orm import Session + +from hullwork import bump, dependencies, dispatch, evidence, osv, refit, work +from hullwork.attempts import finish, has_attempt_left, start +from hullwork.engine import Engine, Phase +from hullwork.manifest import parse_manifest +from hullwork.models import AttemptOutcome, AttemptPhase, Item, ItemState, Lane, Project +from hullwork.sandbox.run import RunResult +from hullwork.states import transition + +MANIFEST = """ +project: p +git: {provider: forgejo, repo: o/r} +autofix: {agent: claude-code, gates: [tests, human-merge]} +tests: "pytest" +test_path: tests +runtime: {base: python-3.12, install: none, dependencies: [requirements.txt]} +""" + +LINTED = MANIFEST.replace( + "gates: [tests, human-merge]", "gates: [tests, lint, human-merge]" +) + 'lint: "ruff check ."\n' + +ENGINE = Engine(name="fake", image="img", protocol="anthropic", command="agent {phase}") + +#: What the fix phase is invoked as, once `Phase.REFIT` exists. +REFIT = "agent refit" +TESTS = "pytest" + +WAS, TO = "4.17.11", "4.17.21" + + +class FakeBox: + """Stands in for the container. Scripted per command, and writes what an agent would write.""" + + def __init__( + self, + worktree: Path, + script: dict[str, int], + writes: dict[str, dict[str, str]], + outputs: dict[str, str] | None = None, + ) -> None: + self.worktree = worktree + self.contract_dir = worktree / "_contract" + self.contract_dir.mkdir(exist_ok=True) + self.script = script + self.writes = writes + self.outputs = outputs or {} + self.ran: list[str] = [] + self.envs: dict[str, dict[str, str]] = {} + # `pytest` runs twice with two different expected results — red first, green after — so a + # double keying only on the command string cannot express this sequence at all. + self.counts: dict[str, int] = {} + + def run(self, command: str, timeout: int, env: dict[str, str] | None = None) -> RunResult: + del timeout + self.ran.append(command) + self.envs[command] = dict(env or {}) + nth = self.counts.get(command, 0) + self.counts[command] = nth + 1 + for name, body in self.writes.get(command, {}).items(): + if name == "__delete__": + (self.worktree / body).unlink() + continue + target = self.worktree / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(body) + code = self.script.get(f"{command}#{nth}", self.script.get(command, 0)) + printed = self.outputs.get(f"{command}#{nth}", self.outputs.get(command, "out")) + return RunResult(command=command, exit_code=code, output=printed, duration_ms=1) + + # Item 058: the agent's phases go through the entry that has a route to the gateway. + run_with_model = run + + +@pytest.fixture +def item(session: Session) -> Item: + project = Project( + slug="p", forge="forgejo", repo="o/r", + webhook_secret_hash="x", # noqa: S106 + ) + session.add(project) + session.flush() + row = Item( + project_id=project.id, fingerprint="fp", + title=f"lodash {WAS} → {TO} breaks the suite", lane=Lane.GREEN, + ) + session.add(row) + session.flush() + return row + + +def _pin(worktree: Path) -> str | None: + """What `requirements.txt` in this tree pins lodash at, or `None`. + + A real reader rather than a canned answer, so the restore and the re-read are exercised + against the same file the double edits — which is the only way the two guards can be shown to + be different guards. + """ + text = (worktree / "requirements.txt").read_text(encoding="utf-8") + for line in text.splitlines(): + if line.startswith("lodash=="): + return line.split("==", 1)[1].strip() + return None + + +def _go( + session: Session, + item: Item, + tmp_path: Path, + script: dict[str, int], + writes: dict[str, dict[str, str]] | None = None, + outputs: dict[str, str] | None = None, + guarded: tuple[str, ...] = ("requirements.txt",), + version_now: Callable[[Path], str | None] | None = None, + manifest_text: str = MANIFEST, +) -> tuple[dispatch.Verdict, FakeBox, Any]: + (tmp_path / "src.py").write_text("x = 1\n") + (tmp_path / "tests").mkdir(exist_ok=True) + (tmp_path / "tests" / "test_a.py").write_text("def test_a():\n assert True\n") + # The tree arrives with the upgrade already applied: that is what makes the first gate red. + (tmp_path / "requirements.txt").write_text(f"lodash=={TO}\n") + box = FakeBox(tmp_path, script, writes or {}, outputs) + attempt = start(session, item) + verdict = dispatch.refit( + session, item, parse_manifest(manifest_text), ENGINE, + box=box, # type: ignore[arg-type] + attempt=attempt, + package="lodash", to=TO, guarded=guarded, + version_now=version_now or _pin, + ) + return verdict, box, attempt + + +REAL_FIX = {"src.py": "x = 2 # works with the new lodash\n"} +REVERT = {"requirements.txt": f"lodash=={WAS}\n"} + +#: The suite failing on the tests the upgrade broke, in the shape a runner prints them. +BROKEN = ( + "FAILED tests/test_a.py::test_shape - TypeError: lodash.merge is not a function\n" + "FAILED tests/test_a.py::test_deep\n" + "2 failed, 40 passed" +) + + +# --- the sequence ------------------------------------------------------------------------- + + +def test_the_red_gate_is_the_upgrade_and_no_agent_is_asked_to_write_a_test( + session: Session, item: Item, tmp_path: Path +) -> None: + """The expensive half of DR-0003 is already paid for, by evidence nobody authored. + + There is no reproduce phase here at all: the failing tests are the project's own, failing + against the upgraded dependency. An agent asked to write one would be authoring the oracle, + which is the one thing no oracle in this product may be. + """ + verdict, box, attempt = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 0}, + writes={REFIT: REAL_FIX}, + outputs={f"{TESTS}#0": BROKEN}, + ) + + assert verdict.outcome is AttemptOutcome.PR_OPEN + assert box.ran == [TESTS, REFIT, TESTS] + assert "agent reproduce" not in box.ran + phases = [step.phase for step in attempt.steps] + assert AttemptPhase.REPRODUCE not in phases + # Recorded as the red gate rather than as a baseline, because that is what it is: the run that + # establishes the failure the fix is judged against. + assert phases == [AttemptPhase.RED_GATE, AttemptPhase.FIX, AttemptPhase.GREEN_GATE] + + +def test_a_suite_that_passes_with_the_upgrade_has_nothing_to_fix( + session: Session, item: Item, tmp_path: Path +) -> None: + """Before the model is called: the breakage did not reproduce, so there is no work here.""" + verdict, box, _ = _go(session, item, tmp_path, script={f"{TESTS}#0": 0}) + + assert verdict.outcome is AttemptOutcome.NOT_REPRODUCIBLE + assert box.ran == [TESTS], "the agent must not be paid for to fix a suite that passes" + assert "passes with" in verdict.detail + + +def test_a_fix_phase_that_changes_nothing_is_not_a_fix( + session: Session, item: Item, tmp_path: Path +) -> None: + verdict, _, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 1}, + writes={}, + ) + + assert verdict.outcome is AttemptOutcome.FAILED + assert verdict.phase is AttemptPhase.FIX + assert "changed nothing" in verdict.detail + + +def test_a_real_fix_publishes_and_the_lock_still_carries_the_upgrade( + session: Session, item: Item, tmp_path: Path +) -> None: + verdict, _, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 0}, + writes={REFIT: REAL_FIX}, + outputs={f"{TESTS}#0": BROKEN}, + ) + + assert verdict.outcome is AttemptOutcome.PR_OPEN + assert verdict.changes.written["src.py"] == REAL_FIX["src.py"].encode() + assert not verdict.reverted + assert TO in verdict.detail, "the artefact has to say which upgrade this makes possible" + + +# --- the guard this item exists to get right ------------------------------------------------ + + +def test_the_dependency_file_is_put_back_when_the_fix_phase_edits_it( + session: Session, item: Item, tmp_path: Path +) -> None: + """Read-only means put back, not merely noticed. + + Restoring rather than detecting is what keeps the revert out of the published change: a + dependency file that reached `changes` would be a pull request that undoes the upgrade it + claims to make possible. + """ + verdict, _, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 0}, + writes={REFIT: {**REAL_FIX, **REVERT}}, + outputs={f"{TESTS}#0": BROKEN}, + ) + + assert (tmp_path / "requirements.txt").read_text() == f"lodash=={TO}\n" + assert "requirements.txt" not in verdict.changes.written + assert verdict.reverted == "requirements.txt" + + +def test_a_green_gate_reached_by_reverting_is_reported_as_a_revert( + session: Session, item: Item, tmp_path: Path +) -> None: + """The whole item, in one test. + + The fix phase puts the old version back and does nothing else. The file is restored before + anything is collected, so there is no change left to gate — and no second gate is paid for, + which is the honest shape rather than a saving: within one attempt the revert could never have + bought a green gate anyway. The image was built before this ran and the phases have no network, + so the installed version does not move whatever the file says. What a revert buys is the + published diff, and that is what the restore takes away. + """ + verdict, box, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 1}, + writes={REFIT: REVERT}, + outputs={f"{TESTS}#0": BROKEN}, + ) + + assert verdict.outcome is not AttemptOutcome.PR_OPEN + assert verdict.outcome is AttemptOutcome.FAILED + assert verdict.reverted == "requirements.txt" + assert "revert" in verdict.detail + assert not verdict.changes, "a revert that was put back leaves nothing to publish" + assert (tmp_path / "requirements.txt").read_text() == f"lodash=={TO}\n" + assert box.ran == [TESTS, REFIT] + + +def test_a_lock_that_no_longer_carries_the_upgrade_is_a_revert_however_it_got_there( + session: Session, item: Item, tmp_path: Path +) -> None: + """The backstop, and it is not the same guard as the restore. + + The restore covers the files this ecosystem's resolver is known to touch. The re-read covers + everything else — a file nobody taught `touches` about, a pin moved somewhere the guard does + not look. A green gate whose tree no longer carries the upgraded version is not a fix. + """ + verdict, _, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 0}, + writes={REFIT: REAL_FIX}, + outputs={f"{TESTS}#0": BROKEN}, + version_now=lambda _worktree: WAS, + ) + + assert verdict.outcome is AttemptOutcome.FAILED + assert "revert" in verdict.detail + assert WAS in verdict.detail and TO in verdict.detail + + +def test_a_tree_that_lost_the_dependency_entirely_is_not_a_fix_either( + session: Session, item: Item, tmp_path: Path +) -> None: + """`None` is not `to`, and the message has to say which of the two happened.""" + verdict, _, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 0}, + writes={REFIT: REAL_FIX}, + outputs={f"{TESTS}#0": BROKEN}, + version_now=lambda _worktree: None, + ) + + assert verdict.outcome is AttemptOutcome.FAILED + assert "no longer pins" in verdict.detail + + +def test_every_file_the_move_can_touch_is_guarded_not_only_the_one_that_pins( + session: Session, item: Item, tmp_path: Path +) -> None: + """Item 175's finding, one layer up. + + `npm install` rewrites `package.json` as well as the lock, so a guard that watched only the + lock would let a fix widen the range back and leave the pin looking untouched. + """ + (tmp_path / "package.json").write_text('{"dependencies": {"lodash": "^4.17.21"}}\n') + verdict, _, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 0}, + writes={REFIT: {**REAL_FIX, "package.json": '{"dependencies": {"lodash": "^4.17.11"}}\n'}}, + outputs={f"{TESTS}#0": BROKEN}, + guarded=("requirements.txt", "package.json"), + ) + + assert (tmp_path / "package.json").read_text() == '{"dependencies": {"lodash": "^4.17.21"}}\n' + assert "package.json" not in verdict.changes.written + assert verdict.reverted == "package.json" + + +def test_a_dependency_file_the_fix_deleted_comes_back( + session: Session, item: Item, tmp_path: Path +) -> None: + """Deleting the pin is a revert with extra steps, and `Changes` learned that lesson once.""" + verdict, _, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 1}, + writes={REFIT: {"__delete__": "requirements.txt"}}, + outputs={f"{TESTS}#0": BROKEN}, + ) + + assert (tmp_path / "requirements.txt").read_text() == f"lodash=={TO}\n" + assert verdict.reverted == "requirements.txt" + assert "requirements.txt" not in verdict.changes.deleted + + +# --- the guards this sequence inherits rather than reimplements ------------------------------- + + +def test_test_infrastructure_the_fix_switched_off_is_still_put_back( + session: Session, item: Item, tmp_path: Path +) -> None: + """Item 046 applies unchanged: a suite that collects nothing passes trivially. + + And it is a `conftest.py` the phase **invented** rather than edited, because that is the shape + this item found the guard did not cover. An agent that cannot make an upgrade fit is exactly + the caller with a reason to switch off the tests it cannot satisfy, so this sequence needs the + fixed guard more than the one it was written for. + """ + verdict, box, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 0, f"{TESTS}#2": 1}, + writes={REFIT: {**REAL_FIX, "conftest.py": "collect_ignore_glob = ['*']\n"}}, + outputs={f"{TESTS}#0": BROKEN}, + ) + + assert verdict.outcome is AttemptOutcome.FAILED + assert verdict.phase is AttemptPhase.GREEN_GATE_RESTORED + assert "conftest.py" in verdict.restored + assert box.ran == [TESTS, REFIT, TESTS, TESTS] + + +def test_the_lint_gate_does_not_discard_a_verified_fix( + session: Session, item: Item, tmp_path: Path +) -> None: + """Item 067 applies unchanged: the lint gate contests the style, not the claim.""" + verdict, _, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 0, "ruff check .": 1}, + writes={REFIT: REAL_FIX}, + outputs={f"{TESTS}#0": BROKEN}, + manifest_text=LINTED, + ) + + assert verdict.outcome is AttemptOutcome.PR_OPEN_LINT_FAILED + + +def test_the_fix_phase_is_told_which_upgrade_and_which_tests( + session: Session, item: Item, tmp_path: Path +) -> None: + """The phase the agent is asked for is its own, not the bug-fixing one. + + `fix` tells the agent about a reproducing test file at a path that does not exist here. A + phase that names evidence nobody wrote is how an attempt spends its one try on a + misunderstanding, which is what item 094 measured. + """ + _, box, _ = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 0}, + writes={REFIT: REAL_FIX}, + outputs={f"{TESTS}#0": BROKEN}, + ) + + assert box.envs[REFIT]["HULLWORK_AGENT_PHASE"] == Phase.REFIT.value + + +# --- the accounting, which is the ordinary one ------------------------------------------------- + + +def test_the_attempt_is_spent_and_accounted_for_exactly_as_any_other( + session: Session, item: Item, tmp_path: Path +) -> None: + """Through `run_one`, because that is what a refit actually goes through in production. + + **The parameter this covers is `sequence`.** Everything `run_one` does — claiming the item, + opening the attempt, the seal, the ceiling checks, publishing, releasing — is about an attempt + rather than about what the attempt was for, so a refit gets all of it by passing a different + sequence and nothing else. Without this test that parameter is exercised nowhere. + + DR-0003's rule is then the ordinary one: this item has had its try, and `has_attempt_left` + says so to whoever asks next. + """ + from functools import partial + + (tmp_path / "src.py").write_text("x = 1\n") + (tmp_path / "tests").mkdir(exist_ok=True) + (tmp_path / "requirements.txt").write_text(f"lodash=={TO}\n") + box = FakeBox( + tmp_path, + {f"{TESTS}#0": 1, f"{TESTS}#1": 0}, + {REFIT: REAL_FIX}, + {f"{TESTS}#0": BROKEN}, + ) + project = session.get(Project, item.project_id) + assert project is not None + project.manifest = parse_manifest(MANIFEST).model_dump(mode="json") + transition(item, ItemState.TRIAGED) + transition(item, ItemState.READY) + session.flush() + published: list[str] = [] + + def publisher(*_a: object) -> str: + published.append("written") + return "somewhere" + + outcome = work.run_one( + session, + work.Eligible(item=item, project=project), + engine=ENGINE, + box_factory=lambda _m: box, + publisher=publisher, + sequence=partial( + dispatch.refit, + package="lodash", to=TO, guarded=("requirements.txt",), version_now=_pin, + ), + ) + + assert outcome.outcome is AttemptOutcome.PR_OPEN + assert published == ["written"] + assert item.state is ItemState.PR_OPEN + assert not has_attempt_left(session, item), "DR-0003: one attempt, then a human" + + +# --- what the reviewer reads ------------------------------------------------------------------ + + +def test_the_artefact_names_the_upgrade_and_the_tests_and_does_not_overclaim( + session: Session, item: Item, tmp_path: Path +) -> None: + """The criterion this item is judged on, read off the document a person actually receives. + + And the sentence that must **not** be there: *a test that failed against unmodified code*. The + code was modified before the first gate ran — by the upgrade — so `evidence`'s ordinary + headline would be false in the one word carrying it, which is the overclaim item 171 removed + from this product once already. + """ + verdict, _, attempt = _go( + session, item, tmp_path, + script={f"{TESTS}#0": 1, f"{TESTS}#1": 0}, + writes={REFIT: REAL_FIX}, + outputs={f"{TESTS}#0": BROKEN, f"{TESTS}#1": "42 passed"}, + ) + finish(session, attempt, verdict.outcome) + + body = evidence.pull_request_body( + item, attempt, detail=verdict.detail, claim=verdict.claim + ) + + assert "unmodified code" not in body + assert "the upgrade is still" in body + # Which upgrade this makes possible. + assert f"lodash {TO}" in body + # And the tests that were failing, from the red gate's own output rather than from prose. + assert "test_shape" in body + assert "exit `1` as it must" in body + assert "exit `0` as it must" in body + + +# --- what a breakage is, and what the agent is told about it ---------------------------------- + + +def _breaks(package: str = "lodash") -> bump.Report: + return bump.Report( + package=package, + was=WAS, + answers=(bump.Answer(bump.Verdict.BREAKS, package, WAS, TO, detail=BROKEN),), + ) + + +def test_an_upgrade_is_built_from_a_breaks_verdict_and_carries_the_failing_tests() -> None: + upgrade = refit.from_report(_breaks(), source="requirements.txt") + + assert upgrade is not None + assert (upgrade.package, upgrade.was, upgrade.to) == ("lodash", WAS, TO) + assert "test_shape" in upgrade.failing + + +def test_a_report_that_did_not_break_is_not_work_for_an_agent() -> None: + """Only `needs work` reaches this. A clean verdict is item 178's to deliver, not this one's.""" + clean = bump.Report( + package="lodash", was=WAS, + answers=(bump.Answer(bump.Verdict.CLEAN, "lodash", WAS, TO),), + ) + red = bump.Report( + package="lodash", was=WAS, + answers=(bump.Answer(bump.Verdict.ALREADY_RED, "lodash", WAS, TO),), + ) + + assert refit.from_report(clean, source="requirements.txt") is None + assert refit.from_report(red, source="requirements.txt") is None + + +def test_a_package_that_broke_and_then_passed_is_not_work_for_an_agent() -> None: + """The case the `needs_of` filter actually exists for, and the two above do not reach. + + `bump.verify` tries candidates in order, so a report can carry a `breaks` answer **and** a + later clean one — the lower fix broke the suite and the higher one did not. That is a package + to take, which is item 178's to deliver, and scanning for a `breaks` answer instead of asking + what the report *needs* would hand it to an agent and pay a model to fix something already + fixed. + + Verified by reintroducing the defect: with the `needs_of` line removed the two tests above + still pass, because a report whose only answer is clean has no `breaks` answer to find. This + one is the only thing between that filter and being dead code. + """ + broke_then_passed = bump.Report( + package="lodash", was=WAS, + answers=( + bump.Answer(bump.Verdict.BREAKS, "lodash", WAS, "4.17.20", detail=BROKEN), + bump.Answer(bump.Verdict.CLEAN, "lodash", WAS, TO), + ), + ) + broke_then_the_suite_went_red = bump.Report( + package="lodash", was=WAS, + answers=( + bump.Answer(bump.Verdict.BREAKS, "lodash", WAS, "4.17.20", detail=BROKEN), + bump.Answer(bump.Verdict.ALREADY_RED, "lodash", WAS, TO), + ), + ) + + assert bump.needs_of(broke_then_passed) is bump.Needs.JUST_TAKE_IT + assert refit.from_report(broke_then_passed, source="requirements.txt") is None + assert refit.from_report(broke_then_the_suite_went_red, source="requirements.txt") is None + + +def test_what_a_move_can_touch_is_asked_of_the_resolver_that_owns_it() -> None: + """Item 175 measured that `npm install` rewrites `package.json` as well as the lock. + + Read from `resolve.touches` rather than listed here, so an ecosystem added there is guarded + without anybody remembering to — and asserted here, because every other test in this file + hands `guarded` in by hand and would pass with this function returning the lock alone. + """ + assert set(refit.guarded_for("package-lock.json")) == {"package.json", "package-lock.json"} + assert set(refit.guarded_for("uv.lock")) == {"pyproject.toml", "uv.lock"} + # A list of versions is the only file its own move touches. + assert refit.guarded_for("requirements.txt") == ("requirements.txt",) + # And a path in a subdirectory is still the file it is named after. + assert set(refit.guarded_for("frontend/package-lock.json")) == { + "package.json", "package-lock.json" + } + + +def test_the_brief_forbids_the_cheat_by_name_and_says_what_happens_if_it_is_tried() -> None: + """An agent that is not told is an agent that will try it, and be reported for it.""" + upgrade = refit.from_report(_breaks(), source="requirements.txt") + assert upgrade is not None + text = refit.brief(upgrade) + + assert "requirements.txt" in text + assert TO in text and WAS in text + assert "test_shape" in text, "the tests it has to make pass are the point of the brief" + lowered = text.lower() + assert "revert" in lowered + assert "read-only" in lowered or "do not" in lowered + + +@pytest.mark.parametrize( + ("name", "body", "expected"), + [ + ("requirements.txt", f"lodash=={TO}\n", TO), + ("requirements.txt", f"lodash=={WAS}\n", WAS), + ("requirements.txt", "something-else==1.0\n", None), + ], +) +def test_the_version_is_read_back_out_of_the_file_that_pins_it( + tmp_path: Path, name: str, body: str, expected: str | None +) -> None: + (tmp_path / name).write_text(body, encoding="utf-8") + upgrade = refit.from_report(_breaks(), source=name) + assert upgrade is not None + + assert refit.version_now(upgrade, tmp_path) == expected + + +def test_the_version_is_read_back_out_of_a_lock_too(tmp_path: Path) -> None: + """Whichever of the four file shapes pins it — item 172's readers, not a fifth parser.""" + (tmp_path / "package-lock.json").write_text( + json.dumps({"packages": {"": {}, "node_modules/lodash": {"version": TO}}}), + encoding="utf-8", + ) + upgrade = refit.from_report(_breaks(), source="package-lock.json") + assert upgrade is not None + + assert refit.version_now(upgrade, tmp_path) == TO + + +def test_a_file_that_is_no_longer_there_reads_as_no_version_rather_than_raising( + tmp_path: Path, +) -> None: + upgrade = refit.from_report(_breaks(), source="requirements.txt") + assert upgrade is not None + + assert refit.version_now(upgrade, tmp_path) is None + + +def test_the_item_carries_the_upgrade_where_a_person_reads_it(session: Session) -> None: + upgrade = refit.from_report(_breaks(), source="requirements.txt") + assert upgrade is not None + manifest = parse_manifest(MANIFEST) + + _, made = refit.stage(session, manifest, upgrade, repo="o/r") + + assert "lodash" in made.title + assert WAS in made.title and TO in made.title + assert made.lane is Lane.GREEN + assert made.lane_reason + + +# --- which of the queue is handed to an agent at all ------------------------------------------- + + +def _finding(name: str, was: str) -> osv.Finding: + return osv.Finding( + dependency=dependencies.Dependency("PyPI", name, was, "requirements.txt"), + advisories=( + osv.Advisory(id=f"GHSA-{name}", summary="something", fixed=(TO,)), + ), + ) + + +def _report(package: str, verdict: bump.Verdict, detail: str = "") -> bump.Report: + return bump.Report( + package=package, was=WAS, + answers=(bump.Answer(verdict, package, WAS, TO, detail=detail),), + ) + + +def _fix_run( + tmp_path: Path, reports: list[bump.Report], monkeypatch: pytest.MonkeyPatch, + fail: str = "", +) -> tuple[list[refit.Upgrade], str]: + """Run the `--fix` half of `deps` with the attempt itself stubbed out. + + What is under test here is the *selection*: which of a queue is worth a model at all, and in + what order. Running the attempt would be testing `_attempt`, which has its own tests and needs + a daemon and a credential. + """ + from hullwork import cli + + asked: list[refit.Upgrade] = [] + + def fake_run(_settings: object, *_a: object, **kwargs: object) -> object: + upgrade = kwargs["upgrade"] if "upgrade" in kwargs else _a[2] + asked.append(upgrade) # type: ignore[arg-type] + if fail: + raise refit.NotUpgradableError(fail) + return SimpleNamespace( + outcome=AttemptOutcome.PR_OPEN, detail="it fits now", pull_request="somewhere-on-disk" + ) + + monkeypatch.setattr(refit, "run", fake_run) + printed = io.StringIO() + code = cli._fix_the_ones_that_break( + argparse.Namespace(into=str(tmp_path / "out"), fix=True), + None, # type: ignore[arg-type] + tmp_path, + ["requirements.txt"], + parse_manifest(MANIFEST), + [_finding(r.package, r.was) for r in reports], + reports, + printed, + ) + assert code == 0 + return asked, printed.getvalue() + + +def test_only_the_ones_that_need_work_are_handed_to_an_agent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A model is the expensive part of this command, so it is spent on one bucket of four. + + The verified-green ones need no agent and are item 178's to deliver; a red baseline is the + project's own suite and nothing can be claimed against it; a blocked one has nothing to try. + """ + reports = [ + _report("clean-one", bump.Verdict.CLEAN), + _report("red-one", bump.Verdict.ALREADY_RED), + _report("blocked-one", bump.Verdict.CANNOT_MOVE), + _report("broken-one", bump.Verdict.BREAKS, BROKEN), + ] + + asked, _ = _fix_run(tmp_path, reports, monkeypatch) + + assert [u.package for u in asked] == ["broken-one"] + + +def test_the_broken_ones_are_worked_easiest_first( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`bump.ranked`'s order, not this command's own. + + A run that is interrupted has then spent its money on the ones a person would have started + with, and the ordering rule stays in the one module that already owns it. + """ + reports = [ + _report("twelve", bump.Verdict.BREAKS, "\n".join(f"FAILED t{i}" for i in range(12))), + _report("two", bump.Verdict.BREAKS, "FAILED a\nFAILED b"), + ] + + asked, _ = _fix_run(tmp_path, reports, monkeypatch) + + assert [u.package for u in asked] == ["two", "twelve"] + + +def test_the_advisory_travels_with_the_upgrade( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The agent's brief names why this upgrade exists, and a reader can go and check it.""" + asked, _ = _fix_run( + tmp_path, [_report("broken-one", bump.Verdict.BREAKS, BROKEN)], monkeypatch + ) + + assert asked[0].advisory == "GHSA-broken-one" + assert "osv.dev" in asked[0].url + + +def test_an_upgrade_that_cannot_be_applied_is_said_rather_than_raised( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """No attempt was begun, so nothing was spent — and the resolver's own words survive. + + A traceback here would end the whole run over one package, which on a queue of six is five + upgrades nobody was told about. + """ + _, printed = _fix_run( + tmp_path, + [_report("broken-one", bump.Verdict.BREAKS, BROKEN)], + monkeypatch, + fail="constrained-by-manifest: the range does not allow it", + ) + + assert "could not be applied" in printed + assert "the range does not allow it" in printed + assert "0 of 1" in printed + + +def test_a_queue_with_nothing_broken_says_so_rather_than_going_quiet( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + asked, printed = _fix_run(tmp_path, [_report("clean-one", bump.Verdict.CLEAN)], monkeypatch) + + assert asked == [] + assert "had nothing to do" in printed + + +def test_two_upgrades_of_one_package_are_two_items(session: Session) -> None: + """The identity of this work is the pair of versions, not the package. + + A fingerprint over the name alone would make the second upgrade a repeat of the first, and + `dedup` would increment a counter instead of creating work. + """ + manifest = parse_manifest(MANIFEST) + first = refit.from_report(_breaks(), source="requirements.txt") + second = bump.Report( + package="lodash", was=WAS, + answers=(bump.Answer(bump.Verdict.BREAKS, "lodash", WAS, "4.17.22", detail=BROKEN),), + ) + other = refit.from_report(second, source="requirements.txt") + assert first is not None and other is not None + + _, a = refit.stage(session, manifest, first, repo="o/r") + _, b = refit.stage(session, manifest, other, repo="o/r") + + assert a.fingerprint != b.fingerprint + + +# --- first contact, which is where item 048's lesson had not reached (item 184) ----------------- + + +def test_fix_without_a_model_refuses_before_a_container_is_built() -> None: + """**Item 048's finding, on the path that had not learned it.** + + Measured by running `--fix` for the first time on 2026-08-09: the refusal was raised inside + `refit.run`, so it arrived **after** every container had been built and every suite run — the + most expensive place available — and it arrived as a `WiringError` traceback rather than as a + refusal, which item 120 is about. The message itself was right; where and how it appeared were + not. + + Knowable from the settings and nothing else, so it costs nothing to answer this early. + """ + from hullwork import cli + from hullwork.config import Settings + + with pytest.raises(cli.CommandError) as refused: + cli._refuse_without_a_model(Settings()) + + assert "no model credential" in str(refused.value) + # And it names the half that needs nothing, because that is what the reader can still run. + assert "--verify" in str(refused.value) + + +def test_fix_asks_for_the_model_before_it_reads_a_single_lock_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """**The call site, which is where the defect was** — and the first version of this test did + not cover it. + + That one asserted `_refuse_without_a_model` raises, which it always did: the function was never + the problem. Deleting the *call* from `_cmd_deps` left every test green and put the refusal back + where it started, minutes and several containers later. Verified by doing exactly that. + + The stub raises something unmistakable, so what this asserts is the **order**: the refusal wins + over the lock-file refusal that the same checkout would otherwise produce. + """ + import argparse + import subprocess + + from hullwork import cli + from hullwork.config import Settings + + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) # noqa: S603, S607 + (tmp_path / "hullwork.yml").write_text( + "project: p\ngit: {provider: forgejo, repo: o/r}\n" + 'tests: "pytest"\ntest_path: tests\n' + "runtime: {base: python-3.12, install: none, dependencies: []}\n" + ) + + def refuse(*_a: object, **_k: object) -> None: + raise cli.CommandError("asked for the model first") + + monkeypatch.setattr(cli, "_refuse_without_a_model", refuse) + + with pytest.raises(cli.CommandError) as refused: + cli._cmd_deps( + argparse.Namespace( + checkout=str(tmp_path), verify=False, fix=True, open=False, into=str(tmp_path) + ), + Settings(), + io.StringIO(), + ) + + assert "asked for the model first" in str(refused.value) + + +def test_verify_alone_never_asks_for_a_model( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The property `deps` is sold on: `--verify` runs the suite against every published fix with + no credential of any kind. Asserted by making the lookup explode.""" + import argparse + import subprocess + + from hullwork import cli + from hullwork.config import Settings + + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) # noqa: S603, S607 + (tmp_path / "hullwork.yml").write_text( + "project: p\ngit: {provider: forgejo, repo: o/r}\n" + 'tests: "pytest"\ntest_path: tests\n' + "runtime: {base: python-3.12, install: none, dependencies: []}\n" + ) + + def explode(*_a: object, **_k: object) -> None: + raise AssertionError("--verify asked for a model credential") + + monkeypatch.setattr(cli, "_refuse_without_a_model", explode) + + with pytest.raises(cli.CommandError) as refused: + cli._cmd_deps( + argparse.Namespace( + checkout=str(tmp_path), verify=True, fix=False, open=False, into=str(tmp_path) + ), + Settings(), + io.StringIO(), + ) + + assert "no lock file" in str(refused.value) + + +def test_fix_refuses_without_the_gateway_image_before_anything_is_built( + monkeypatch: pytest.MonkeyPatch +) -> None: + """**Item 048's finding for the third time**, found by running `--fix` against a real model. + + It died with `could not start the gateway / Unable to find image 'hullwork:dev' locally` — + after OSV, four image builds and two suite runs. The gateway is where the model credential + lives so the sandbox never holds it (DR-0004), and **every** agent path starts one, so this is + a fact about the instance rather than about the newest command. + + The refusal has to say what the image is for and how to build it: *image not found* is not + something anybody can act on. + """ + from hullwork import cli + from hullwork import work as work_module + from hullwork.config import Settings + + monkeypatch.setattr(work_module, "_model_credential", lambda _s: "a-key") + monkeypatch.setattr( + "hullwork.sandbox.net.why_the_gateway_cannot_start", + lambda **_k: "the gateway image `hullwork:dev` is not on this Docker daemon", + ) + + with pytest.raises(cli.CommandError, match="gateway image"): + cli._refuse_without_a_model(Settings()) + + +def test_the_gateway_refusal_names_what_to_run_and_what_not_to() -> None: + """`docker compose build` **does not make it**, and this repository said it did. + + The compose file pins a published image and has no build stage — its own comment says to add + one — so that instruction exits 0 and produces nothing. Measured on 2026-08-09 while chasing + exactly this skip, and the sentence a reader gets now says both halves. + """ + from hullwork.sandbox import net + + said = net.why_the_gateway_cannot_start.__doc__ or "" + assert "expensive place available" in said + + # The refusal itself, built without a daemon by asking for an image that cannot exist. + refusal = net.why_the_gateway_cannot_start(docker="a-binary-that-is-not-here") + assert refusal is None, "a missing docker client is doctor's question, not this one's" diff --git a/tests/test_resolve.py b/tests/test_resolve.py new file mode 100644 index 0000000..26dc10f --- /dev/null +++ b/tests/test_resolve.py @@ -0,0 +1,169 @@ +"""Moving a resolved graph by running the ecosystem's own tool. Item 175, DR-0016. + +**No test here starts a container**: `run` is injected, so what these assert is the decision logic +— which command, which files, and above all whether the tool is believed. The Docker path is +measured once by hand and written into the item. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +from pathlib import Path + +from hullwork import resolve + +NPM_LOCK = """ +{ + "lockfileVersion": 3, + "packages": { + "": {"name": "app", "version": "1.0.0"}, + "node_modules/lodash": {"version": "%s"} + } +} +""" + +UV_LOCK = """ +[[package]] +name = "jinja2" +version = "%s" +""" + + +def test_each_lock_file_has_exactly_one_owner() -> None: + assert resolve.resolver_for("package-lock.json").lock == "package-lock.json" # type: ignore[union-attr] + assert resolve.resolver_for("deep/uv.lock").lock == "uv.lock" # type: ignore[union-attr] + assert resolve.resolver_for("poetry.lock").lock == "poetry.lock" # type: ignore[union-attr] + + +def test_a_lock_nobody_can_move_has_no_resolver() -> None: + """`None` is not a gap to fill silently — item 173's refusal still applies, by name.""" + assert resolve.resolver_for("Cargo.lock") is None + assert resolve.resolver_for("requirements.txt") is None + + +def test_the_command_names_the_package_and_the_version() -> None: + npm = resolve.resolver_for("package-lock.json") + assert npm is not None + built = resolve.command_for(npm, "lodash", "4.17.21") + + assert "lodash@4.17.21" in built + # Moves the graph without downloading node_modules: seconds rather than minutes. + assert "--package-lock-only" in built + + +def test_the_manifest_is_required_and_named_when_absent() -> None: + """A `uv.lock` with no `pyproject.toml` cannot be resolved by anything. + + Checked before the container starts, because finding it out after pulling an image is a minute + spent on a fact that was on disk the whole time. + """ + uv = resolve.resolver_for("uv.lock") + assert uv is not None + assert resolve.missing_from(uv, ["uv.lock"]) == ["pyproject.toml"] + assert resolve.missing_from(uv, ["pyproject.toml", "uv.lock"]) == [] + # And from a subdirectory, because a monorepo pins per package. + assert resolve.missing_from(uv, ["svc/pyproject.toml", "svc/uv.lock"]) == [] + + +def test_the_version_is_read_back_out_of_each_lock_shape() -> None: + assert resolve.version_in_lock(NPM_LOCK % "4.17.21", "package-lock.json", "lodash") == "4.17.21" + assert resolve.version_in_lock(UV_LOCK % "3.1.6", "uv.lock", "jinja2") == "3.1.6" + # Spelling is not identity: lock files disagree about which one they write. + assert resolve.version_in_lock(UV_LOCK % "3.1.6", "uv.lock", "Jinja2") == "3.1.6" + assert resolve.version_in_lock(UV_LOCK % "3.1.6", "uv.lock", "absent") is None + + +def test_a_resolver_that_moved_nothing_is_not_a_success(tmp_path: Path) -> None: + """**The defect this whole module is careful about.** + + Every one of these tools resolves within whatever range the manifest allows and exits 0. A + `"^4.17.11"` answers success having never gone near 5.x. Believing the exit code publishes a + `clean` verdict for an upgrade that never happened — the worst artefact this can emit. + """ + (tmp_path / "package-lock.json").write_text(NPM_LOCK % "4.17.20", encoding="utf-8") + npm = resolve.resolver_for("package-lock.json") + assert npm is not None + + result = resolve.upgrade( + resolver=npm, worktree=tmp_path, package="lodash", version="5.0.0", + present=["package.json", "package-lock.json"], + run=lambda *_: (0, "up to date"), + ) + + assert result.outcome is resolve.Outcome.CONSTRAINED + assert result.ok is False + assert "still 4.17.20, not 5.0.0" in result.detail + assert "range in the manifest" in result.detail + + +def test_a_resolver_that_moved_the_graph_is_a_success(tmp_path: Path) -> None: + lock = tmp_path / "package-lock.json" + lock.write_text(NPM_LOCK % "4.17.11", encoding="utf-8") + npm = resolve.resolver_for("package-lock.json") + assert npm is not None + + def run(_resolver: resolve.Resolver, _dir: Path, _command: str) -> tuple[int, str]: + # What the real tool does: rewrites the file in place, through the bind mount. + lock.write_text(NPM_LOCK % "4.17.21", encoding="utf-8") + return 0, "" + + result = resolve.upgrade( + resolver=npm, worktree=tmp_path, package="lodash", version="4.17.21", + present=["package.json", "package-lock.json"], run=run, + ) + + assert result.outcome is resolve.Outcome.RESOLVED + assert result.ok is True + + +def test_a_failing_tool_carries_its_own_words(tmp_path: Path) -> None: + """`npm ERR! code ETARGET` is what the operator needs; "resolution failed" is not.""" + (tmp_path / "package-lock.json").write_text(NPM_LOCK % "4.17.11", encoding="utf-8") + npm = resolve.resolver_for("package-lock.json") + assert npm is not None + + result = resolve.upgrade( + resolver=npm, worktree=tmp_path, package="lodash", version="99.0.0", + present=["package.json", "package-lock.json"], + run=lambda *_: (1, "npm ERR! code ETARGET\nnpm ERR! notarget No matching version"), + ) + + assert result.outcome is resolve.Outcome.FAILED + assert "ETARGET" in result.detail + + +def test_nothing_runs_when_a_needed_file_is_absent(tmp_path: Path) -> None: + ran: list[str] = [] + npm = resolve.resolver_for("package-lock.json") + assert npm is not None + + def run(_resolver: resolve.Resolver, _dir: Path, _command: str) -> tuple[int, str]: + ran.append("ran") + return 0, "" + + result = resolve.upgrade( + resolver=npm, worktree=tmp_path, package="lodash", version="4.17.21", + present=["package-lock.json"], run=run, + ) + + assert result.outcome is resolve.Outcome.MISSING + assert "package.json" in result.detail + assert ran == [], "no container may start for a file that is missing on disk" + + +def test_a_resolver_may_rewrite_every_file_it_needs_not_only_the_lock() -> None: + """**Measured, and it surprised the design.** + + `npm install lodash@4.17.21 --package-lock-only` rewrites `package.json` too, moving its range + from `^4.17.11` to `^4.17.21`. Correct for an upgrade, and not what the caller assumed. + + The consequence is item 174's defect one file over: restoring only the lock between candidates + leaves the manifest moved, so the next candidate resolves against a range the previous one + widened — and nothing in the output would say so. + """ + for resolver in resolve.RESOLVERS: + assert resolve.touches(resolver) == resolver.needs + assert resolver.lock in resolve.touches(resolver) + # The manifest is in there too, which is the whole point of this test. + assert len(resolve.touches(resolver)) >= 2 diff --git a/tests/test_the_desk_it_cleared.py b/tests/test_the_desk_it_cleared.py new file mode 100644 index 0000000..6244b38 --- /dev/null +++ b/tests/test_the_desk_it_cleared.py @@ -0,0 +1,294 @@ +"""What arrived, and how much of it left with evidence attached. Item 183, DR-0017. + +The decision signed on 2026-08-09 says what the product is measured by, and it is not `Funnel`: +that one's denominator is *attempts that spent an item's one try*, so every question it can answer +has the shape **of the attempts we made, how did they go**. The number signed for has *what arrived* +as its denominator, which is a different question and can produce a much worse answer. + +**Three cases here exist because they are the ones designed to be miscounted**, and each is a way +the product could claim credit it has not earned: an item a person fixed themselves, an item whose +attempt was abandoned by the infrastructure, and a red-lane item nobody was ever allowed to attempt. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy.orm import Session + +from hullwork import outcomes +from hullwork.models import ( + Attempt, + AttemptOutcome, + AttemptPhase, + Item, + ItemState, + Lane, + Project, +) + + +@pytest.fixture +def project(session: Session) -> Project: + made = Project( + slug="p", forge="forgejo", repo="o/r", + webhook_secret_hash="x", # noqa: S106 + ) + session.add(made) + session.flush() + return made + + +def _item( + session: Session, project: Project, state: ItemState, *, lane: Lane = Lane.GREEN, n: int = 0 +) -> Item: + row = Item( + project_id=project.id, fingerprint=f"fp-{state.value}-{lane.value}-{n}", + title="something", lane=lane, state=state, + ) + session.add(row) + session.flush() + return row + + +def _attempt( + session: Session, + item: Item, + outcome: AttemptOutcome | None, + *, + consumed: bool = True, + rehearsal: bool = False, +) -> Attempt: + row = Attempt( + item_id=item.id, phase_reached=AttemptPhase.PUBLISH, + outcome=outcome, consumed=consumed, rehearsal=rehearsal, + ) + session.add(row) + session.flush() + return row + + +# --- the denominator, which is the whole change ------------------------------------------------ + + +def test_the_denominator_is_what_arrived_and_not_what_was_attempted( + session: Session, project: Project +) -> None: + """`Funnel` cannot express this, and that is why it is being added rather than extended. + + Nine claims arrive; one is attempted. `Funnel` reports a denominator of one and every ratio it + can form is about that one. The question DR-0017 signed for is what happened to the nine. + """ + settled = _item(session, project, ItemState.PR_OPEN) + _attempt(session, settled, AttemptOutcome.PR_OPEN) + for n in range(8): + _item(session, project, ItemState.TRIAGED, n=n) + + desk = outcomes.desk(session) + + assert desk.arrived == 9 + assert outcomes.funnel(session).fair_try == 1, "the old denominator, for contrast" + + +def test_the_buckets_sum_to_what_arrived_and_nothing_is_in_two( + session: Session, project: Project +) -> None: + """A report whose parts do not add up is a report nobody can act on.""" + _attempt(session, _item(session, project, ItemState.PR_OPEN), AttemptOutcome.PR_OPEN) + _attempt( + session, _item(session, project, ItemState.NOT_REPRODUCIBLE, n=1), + AttemptOutcome.NOT_REPRODUCIBLE, + ) + _item(session, project, ItemState.READY, n=2) + _item(session, project, ItemState.HUMAN_ONLY, lane=Lane.RED, n=3) + _attempt(session, _item(session, project, ItemState.IN_PROGRESS, n=4), None) + + desk = outcomes.desk(session) + + assert desk.arrived == 5 + assert ( + desk.left_with_evidence + desk.still_waiting + desk.handed_over + desk.running + == desk.arrived + ) + + +# --- the three that are designed to be miscounted ---------------------------------------------- + + +def test_an_item_a_person_fixed_themselves_is_not_ours( + session: Session, project: Project +) -> None: + """**`done` is reached two ways and there is no state history to tell them apart.** + + A merged pull request and a person closing their own issue both land here. The attempt trail is + what separates them, and counting by state would have the product claiming credit for somebody + else's afternoon — which is the single most dishonest thing this number could do. + """ + # The one a person closed themselves. It needs to exist and nothing here reads it. + _item(session, project, ItemState.DONE) + ours = _item(session, project, ItemState.DONE, n=1) + _attempt(session, ours, AttemptOutcome.PR_OPEN) + + desk = outcomes.desk(session) + + assert desk.left_with_evidence == 1, "only the one with a verdict behind it" + assert desk.arrived == 2 + + +def test_an_abandoned_attempt_left_nothing_on_anybodys_desk( + session: Session, project: Project +) -> None: + """The endpoint was unreachable, the sandbox would not start. No gate ran, so nothing is known. + + `abandoned` does not consume an item's attempt precisely because it says nothing about the + claim, and a number that counted it would be counting the infrastructure's bad days as work. + """ + stalled = _item(session, project, ItemState.READY) + _attempt(session, stalled, AttemptOutcome.ABANDONED, consumed=False) + + desk = outcomes.desk(session) + + assert desk.left_with_evidence == 0 + assert desk.still_waiting == 1, "it is back in the queue, which is where it is" + + +def test_a_red_lane_item_was_added_to_the_desk_rather_than_removed( + session: Session, project: Project +) -> None: + """The row `Funnel` cannot have, and the reason this number is worth building. + + DR-0017's own Context says the first half of the pipeline is a **cost**: a team with a tracker + has more issues than it can serve, and the opening move adds to the pile. An item nobody may + attempt is Hullwork putting work on somebody's desk, and the count has to say so. + """ + _item(session, project, ItemState.HUMAN_ONLY, lane=Lane.RED) + _item(session, project, ItemState.REJECTED, n=1) + + desk = outcomes.desk(session) + + assert desk.handed_over == 2 + assert desk.left_with_evidence == 0 + + +def test_a_rehearsal_is_not_a_desk_anybody_cleared( + session: Session, project: Project +) -> None: + """It publishes nothing, so no forge state and nobody's queue changed. `Funnel`'s rule, held.""" + rehearsed = _item(session, project, ItemState.READY) + _attempt(session, rehearsed, AttemptOutcome.PR_OPEN, rehearsal=True) + + desk = outcomes.desk(session) + + assert desk.left_with_evidence == 0 + assert desk.still_waiting == 1 + + +# --- what it says, which is the part a person reads -------------------------------------------- + + +def test_a_refusal_is_reported_beside_a_change_and_not_inside_a_total( + session: Session, project: Project +) -> None: + """The second consequence of DR-0017, made visible rather than averaged away. + + *"I could not verify this" is a first-class result*, so a total that hides how much of the + number it is would be the one place this product rounds its own honesty off. + """ + _attempt(session, _item(session, project, ItemState.PR_OPEN), AttemptOutcome.PR_OPEN) + _attempt( + session, _item(session, project, ItemState.NOT_REPRODUCIBLE, n=1), + AttemptOutcome.NOT_REPRODUCIBLE, + ) + _attempt(session, _item(session, project, ItemState.FAILED, n=2), AttemptOutcome.FAILED) + + desk = outcomes.desk(session) + + assert desk.left_with_evidence == 3 + assert desk.with_a_change == 1 + assert desk.with_a_refusal == 2 + said = " ".join(outcomes.desk_lines(desk)) + assert "1" in said and "2" in said + assert "refus" in said.lower() + + +def test_an_instance_that_attempted_nothing_says_so_in_words( + session: Session, project: Project +) -> None: + """Zeros read as *nothing happened*; this instance has claims and has cleared none of them. + + That is a different fact and the one worth printing on a first day — it is the state every + instance starts in, and the row of noughts that used to stand for it reads like a failure. + """ + for n in range(4): + _item(session, project, ItemState.TRIAGED, n=n) + + said = " ".join(outcomes.desk_lines(outcomes.desk(session))) + + assert "4" in said + assert "0 " not in said, "it says what is true rather than printing noughts" + + +def test_an_instance_with_nothing_at_all_says_nothing(session: Session) -> None: + """No claims have arrived, so there is no desk to report on. Silence, like `lines`.""" + assert outcomes.desk_lines(outcomes.desk(session)) == [] + + +def test_what_was_added_is_not_phrased_as_an_achievement( + session: Session, project: Project +) -> None: + """It is the row that can embarrass this product, and rounding it into good news is the way it + would stop doing that.""" + _attempt(session, _item(session, project, ItemState.PR_OPEN), AttemptOutcome.PR_OPEN) + _item(session, project, ItemState.HUMAN_ONLY, lane=Lane.RED, n=1) + + said = " ".join(outcomes.desk_lines(outcomes.desk(session))) + + assert "onto" in said or "added" in said or "put on" in said + for congratulation in ("successfully", "great", "achieved", "handled"): + assert congratulation not in said.lower() + + +def test_the_json_carries_the_parts_so_an_operator_computes_their_own_ratio( + session: Session, project: Project +) -> None: + """No percentage, for `Funnel`'s reason: six samples cannot carry that precision.""" + _attempt(session, _item(session, project, ItemState.PR_OPEN), AttemptOutcome.PR_OPEN) + + payload = outcomes.desk(session).as_dict() + + assert payload["arrived"] == 1 + assert payload["left_with_evidence"] == 1 + assert not any("percent" in key or "rate" in key for key in payload) + + +# --- and it has to be on the surface a person opens, not only in a terminal -------------------- + + +def test_the_number_is_on_the_page_and_not_only_in_the_terminal( + session: Session, project: Project +) -> None: + """**The defect item 136 already found on this page once**, reintroduced by item 183 and caught + the same day. + + That item's whole finding was three facts the instance knew and put where nobody reading would + find them. This number was added to `status` and to `--json` and not here — and the interface + design, rewritten the day before under the same decision, says this surface exists to show + **what was verified and what was not**. A count of attempts is not that; this is. + + Ordered before the attempts block for `status`'s reason: the wider denominator first. + """ + from hullwork import page + from hullwork.config import Settings + + _attempt(session, _item(session, project, ItemState.PR_OPEN), AttemptOutcome.PR_OPEN) + _item(session, project, ItemState.HUMAN_ONLY, lane=Lane.RED, n=1) + # Committed rather than flushed: this page reads through its own transaction and cannot see + # uncommitted state, which is invisible in production and is the whole of the difference here. + session.commit() + + body = page.instance(session, Settings(), error_reporting=False) + + assert "went onto your desk rather than off it" in body + assert "2 claim(s) have arrived" in body + assert body.index("how much left your desk") < body.index("What its attempts came to") diff --git a/tests/test_the_documentation_describes_the_published_artefact.py b/tests/test_the_documentation_describes_the_published_artefact.py index 2249307..9482165 100644 --- a/tests/test_the_documentation_describes_the_published_artefact.py +++ b/tests/test_the_documentation_describes_the_published_artefact.py @@ -54,6 +54,7 @@ "docs/install.md", "docs/releasing.md", "docs/status.md", + "docs/what-hullwork-is.md", ) def _withheld() -> set[str]: @@ -297,3 +298,57 @@ def test_every_environment_variable_named_in_the_documents_exists(document: str) } assert not (written - real), f"{document} names settings that do not exist: {written - real}" + + +#: The sentence `docs/what-hullwork-is.md` names as partial, and the reason it is. Item 181. +#: +#: **Matched case-insensitively, and the first version of this was not.** It missed the instance +#: that mattered most: line 1 of `README.md`, the banner's alt text, which begins with a lowercase +#: *from* because it continues a sentence. That is the first line a screen reader announces and what +#: renders when the image does not load — so the guard passed while the very first words of the page +#: still said the thing it exists to keep out. +#: +#: It is not wrong — it is one signal's endpoints, offered where a reader is deciding what this is. +#: The canonical page puts it plainly: *"None of them are wrong. All of them are partial."* A +#: description that answers a narrower question than the product does is a description that selects +#: the wrong readers, and this is the one sentence PyPI shows. +THE_PIPELINE_SENTENCE = "From production errors to reviewable" + +#: The hand-written image sources. Globbed rather than listed, because unlike a document an image is +#: never *deliberately* exempt from saying what the product is — there is no equivalent of the +#: withheld-document case here, so a new one should be bound the moment it is added. +IMAGES = tuple(sorted(str(p.relative_to(ROOT)) for p in (ROOT / "images").glob("*.svg"))) + + +def test_no_published_document_describes_the_product_by_its_plumbing() -> None: + """Item 181's falsifiable gate, the half that can be run. + + **Failing when it was written**, which is what made it a gate rather than a decoration: + `pyproject.toml` carried the sentence as its `description`, so it was the first thing PyPI + showed and the first thing a reader met. + + **The images are checked too, and the first version of this said they could not be.** It called + them "rendered assets" whose text a guard would trip over on a re-export. That was true of + `social-preview.png` and false of every SVG in `images/`: those are hand-written source, with + their own comments explaining their own layout, so a text search over them is exactly as stable + as one over Markdown. The PNG has no text a guard can read — it is covered instead by being + generated from `social-preview.svg` by `scripts/render-social-preview.sh`, both committed. + + Alt text counts, and it is where this guard first failed: line 1 of the README is the banner's + alternative text, which is what a screen reader announces and what renders when the image does + not load. + """ + carrying = [ + name + for name in (*PUBLISHED, "pyproject.toml", *IMAGES) + if THE_PIPELINE_SENTENCE.lower() in _text(name).lower() + # The canonical page *quotes* the sentence in order to name it as partial, which is the + # opposite of using it. Exempted by name rather than by a cleverer matcher: one exemption + # anybody can read beats a rule nobody can. + and name != "docs/what-hullwork-is.md" + ] + + assert carrying == [], ( + f"{carrying} describe Hullwork by one signal's endpoints. " + f"docs/what-hullwork-is.md says what it is; these have not caught up." + ) diff --git a/tests/test_upgrades.py b/tests/test_upgrades.py new file mode 100644 index 0000000..d015d18 --- /dev/null +++ b/tests/test_upgrades.py @@ -0,0 +1,616 @@ +"""Open the verified-green ones. Item 178, DR-0018 step 3. + +The forge is a double, because what is under test is *what may be opened and what it says* — and +the one thing this must never do is open something that was not run. A real forge would prove the +HTTP and not the rule. + +**The claim in a pull request body is the most dangerous sentence this product emits.** It arrives +under Hullwork's own account, in a place a human is meant to trust, next to a diff they are being +asked to merge. So the wording is asserted on the rendered text rather than on the function that +produces it. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + +from hullwork import bump, evidence, osv, upgrades +from hullwork.forge import BranchExistsError, ForgePullRequest + +WAS, TO = "2.4.1", "2.10.1" +BASE = "a" * 40 + + +@dataclass +class FakeForge: + """A code forge that records rather than pushes. Item 022's protocol, nothing more.""" + + branches: list[str] = field(default_factory=list) + commits: list[tuple[str, str, tuple[str, ...]]] = field(default_factory=list) + pulls: list[dict[str, object]] = field(default_factory=list) + #: Branches that already exist, so a second run can be expressed. + taken: tuple[str, ...] = () + known: dict[str, str] = field(default_factory=lambda: {"requirements.txt": "blob1"}) + + def default_branch(self, repo: str) -> str: + del repo + return "main" + + def create_branch(self, repo: str, name: str, from_ref: str) -> None: + del repo, from_ref + if name in self.taken: + raise BranchExistsError(name) + self.branches.append(name) + + def file_sha(self, repo: str, path: str, ref: str) -> str | None: + del repo, ref + return self.known.get(path) + + def commit_files( + self, repo: str, branch: str, message: str, changes: object, *, + author: str, email: str, + ) -> str: + del repo, author, email + paths = tuple(sorted(c.path for c in changes)) # type: ignore[attr-defined] + self.commits.append((branch, message, paths)) + return "c" * 40 + + def open_draft_pull_request( + self, repo: str, head: str, base: str, title: str, body: str, + label_ids: list[int] | None = None, + ) -> ForgePullRequest: + del repo, label_ids + self.pulls.append({"head": head, "base": base, "title": title, "body": body}) + return ForgePullRequest( + number=len(self.pulls), title=title, + html_url=f"https://forge/pull/{len(self.pulls)}", draft=True, + ) + + def close(self) -> None: + return None + + +def _answer( + verdict: bump.Verdict, package: str = "jinja2", to: str = TO, detail: str = "" +) -> bump.Answer: + return bump.Answer(verdict, package, WAS, to, detail=detail) + + +def _clean(package: str = "jinja2", to: str = TO) -> bump.Answer: + """A clean answer carrying what the gates actually ran against.""" + return bump.Answer( + bump.Verdict.CLEAN, package, WAS, to, + files={"requirements.txt": f"{package}=={to}\n".encode()}, + runs=bump.Runs( + command="pytest -q", + before_exit=0, after_exit=0, + before_summary="248 passed in 31.02s", + after_summary="248 passed in 30.44s", + ), + ) + + +def _report(package: str, *answers: bump.Answer) -> bump.Report: + return bump.Report(package=package, was=WAS, answers=answers) + + +def _every(text: str, needle: str) -> list[int]: + """Every index `needle` occurs at. One occurrence checked out of two is not a check.""" + found, at = [], text.find(needle) + while at != -1: + found.append(at) + at = text.find(needle, at + 1) + return found + + +ADVISORIES = ( + osv.Advisory( + id="GHSA-462w-v97r-4m45", + summary="Jinja2 sandbox escape via str.format", + fixed=(TO,), + ), +) + + +# --- what may be opened, and what may never --------------------------------------------------- + + +def test_only_the_verified_green_ones_are_eligible() -> None: + """A pull request from Hullwork means *this was run and it passed*. + + The moment it can mean anything else the claim is worth nothing, so every other bucket is + asserted out by name rather than by the absence of a test. + """ + reports = [ + _report("clean-one", _clean("clean-one")), + _report("broken-one", _answer(bump.Verdict.BREAKS, "broken-one", detail="FAILED a")), + _report("red-one", _answer(bump.Verdict.ALREADY_RED, "red-one")), + _report("stuck-one", _answer(bump.Verdict.CANNOT_MOVE, "stuck-one")), + _report("unbuildable", _answer(bump.Verdict.WILL_NOT_INSTALL, "unbuildable")), + ] + + assert [r.package for r in upgrades.eligible(reports)] == ["clean-one"] + + +def test_a_package_that_broke_before_it_passed_is_still_eligible() -> None: + """`verify` tries candidates in order, and the one that settled is the one that gets opened.""" + report = _report( + "jinja2", + _answer(bump.Verdict.BREAKS, "jinja2", to="2.9.0", detail="FAILED a"), + _clean("jinja2"), + ) + + assert upgrades.eligible([report]) == [report] + settled = report.settled + assert settled is not None and settled.to == TO + + +def test_a_clean_answer_with_nothing_to_commit_is_refused_rather_than_opened() -> None: + """The files are the diff. Without them there is a body making a claim about an empty commit. + + This is the shape item 045 is about, one product over: what was tested and what is published + have to be the same tree, and a pull request that carries no tree at all cannot be either. + """ + barren = _report("jinja2", bump.Answer(bump.Verdict.CLEAN, "jinja2", WAS, TO)) + + assert upgrades.eligible([barren]) == [] + + +# --- the opening ------------------------------------------------------------------------------ + + +def test_one_pull_request_per_package_and_a_run_of_three_opens_three() -> None: + """Never a batch: a grouped upgrade that breaks cannot be bisected without undoing our work.""" + forge = FakeForge() + reports = [_report(name, _clean(name)) for name in ("aaa", "bbb", "ccc")] + + opened = upgrades.open_them( + forge, repo="o/r", reports=reports, advisories={}, base_sha=BASE, permitted=True + ) + + assert len(opened) == 3 + assert len(forge.pulls) == 3 + assert len({p["head"] for p in forge.pulls}) == 3, "three branches, not one" + for commit in forge.commits: + assert commit[2] == ("requirements.txt",) + + +def test_the_branch_names_the_upgrade_so_a_second_run_opens_nothing() -> None: + """No database in this path, so the branch name *is* the record of what was opened. + + A second pass over an unchanged repository asks for a branch that exists and is told so by the + forge, which is the same answer `work.publish` already relies on. Nothing is opened twice and + nothing has to be remembered between runs. + """ + reports = [_report("jinja2", _clean())] + first = FakeForge() + upgrades.open_them( + first, repo="o/r", reports=reports, advisories={}, base_sha=BASE, permitted=True + ) + branch = first.branches[0] + + again = FakeForge(taken=(branch,)) + opened = upgrades.open_them( + again, repo="o/r", reports=reports, advisories={}, base_sha=BASE, permitted=True + ) + + assert opened == [] + assert again.pulls == [] + assert branch == upgrades.branch_for("jinja2", WAS, TO) + + +def test_the_branch_survives_a_package_name_a_ref_cannot_carry() -> None: + """`@scope/pkg` is an ordinary npm name and not an ordinary git ref. + + **The character that matters is `/`, and the first version of this test did not check it.** + Verified by neutering the sanitiser: `@babel/core` came through whole, the name still began + with the prefix and still contained none of the characters git documents as forbidden, and this + passed. What it produces is `hullwork/deps/@babel/core-7.0.0-7.24.0` — a fourth path level, + which git will happily create and which collides with any branch named `hullwork/deps/@babel`. + + So the assertion is on the shape: three segments, and the last one is the whole upgrade. + """ + name = upgrades.branch_for("@babel/core", "7.0.0", "7.24.0") + + assert name.split("/")[:2] == ["hullwork", "deps"] + assert len(name.split("/")) == 3, f"the package invented a path level: {name}" + assert " " not in name + for forbidden in ("~", "^", ":", "?", "*", "[", "\\", "@{", ".."): + assert forbidden not in name + # And it still says what it is about, which is the other half of the name's job. + assert "babel" in name and "7.24.0" in name + + +def test_two_upgrades_of_one_package_are_two_branches() -> None: + """Otherwise next month's upgrade collides with this month's and opens nothing, silently. + + The failure mode is the bad one: the forge answers `BranchExistsError`, this treats it as + *already opened*, and a real upgrade never reaches anybody. Verified by dropping the versions + from the name, at which point the test above still passed because it computes what it expects + with the same function. + """ + first = upgrades.branch_for("jinja2", "2.4.1", "2.10.1") + second = upgrades.branch_for("jinja2", "2.10.1", "3.1.4") + + assert first != second + + +def test_a_branch_that_exists_and_a_forge_that_refused_are_told_apart( + caplog: pytest.LogCaptureFixture +) -> None: + """Both open nothing, and the operator is told they are different things. + + The terminal says *already open from an earlier run, or refused by the forge — the log says + which*, so the log has to actually say which. Nothing else can tell these apart: from the + caller's side both are an empty list. + """ + from hullwork.forge import ForgeError + + reports = [_report("jinja2", _clean())] + taken = FakeForge(taken=(upgrades.branch_for("jinja2", WAS, TO),)) + with caplog.at_level("INFO", logger="hullwork.upgrades"): + upgrades.open_them( + taken, repo="o/r", reports=reports, advisories={}, base_sha=BASE, permitted=True + ) + already = caplog.text + caplog.clear() + + refusing = FakeForge() + + def refuse(repo: str, name: str, from_ref: str) -> None: + raise ForgeError("the forge said no") + + refusing.create_branch = refuse # type: ignore[method-assign] + with caplog.at_level("INFO", logger="hullwork.upgrades"): + upgrades.open_them( + refusing, repo="o/r", reports=reports, advisories={}, base_sha=BASE, permitted=True + ) + + assert "already opened" in already + assert "already opened" not in caplog.text + assert "could not branch" in caplog.text + + +def test_the_pull_request_is_rooted_at_the_commit_the_gates_ran_against() -> None: + """Not at wherever the default branch points now — that is a tree nobody tested.""" + forge = FakeForge() + seen: list[str] = [] + original = forge.create_branch + + def watched(repo: str, name: str, from_ref: str) -> None: + seen.append(from_ref) + original(repo, name, from_ref) + + forge.create_branch = watched # type: ignore[method-assign] + upgrades.open_them( + forge, repo="o/r", reports=[_report("jinja2", _clean())], + advisories={}, base_sha=BASE, permitted=True, + ) + + assert seen == [BASE] + + +def test_a_forge_that_refuses_one_does_not_cost_the_others() -> None: + """A queue of five with one bad name is four pull requests, not a traceback.""" + from hullwork.forge import ForgeError + + forge = FakeForge() + calls = {"n": 0} + + def sometimes(repo: str, name: str, from_ref: str) -> None: + calls["n"] += 1 + if calls["n"] == 1: + raise ForgeError("the forge said no") + forge.branches.append(name) + + forge.create_branch = sometimes # type: ignore[method-assign] + reports = [_report(name, _clean(name)) for name in ("aaa", "bbb", "ccc")] + + opened = upgrades.open_them( + forge, repo="o/r", reports=reports, advisories={}, base_sha=BASE, permitted=True + ) + + assert len(opened) == 2 + + +# --- the credential, which is the whole reason this item is amber ----------------------------- + + +def test_open_refuses_before_anything_is_run_when_there_is_no_credential(tmp_path: Path) -> None: + """The refusal has to arrive before two container builds, not after them. + + Same lesson as `_manifest_for_verify` and with more at stake: this is the one flag in `deps` + that writes to somebody's repository, and a refusal that lands after the work it invalidates is + a refusal printed underneath its own contradiction. + """ + from hullwork.cli import CommandError, _forge_for_opening + from hullwork.config import Settings + + with pytest.raises(CommandError) as refused: + _forge_for_opening(Settings(), tmp_path) + + assert "HULLWORK_FORGE_CODE_TOKEN" in str(refused.value) + # And it names the thing that needs nothing, because that is the honest way to see what this + # would have opened. + assert "--verify" in str(refused.value) + + +def test_verify_alone_never_reaches_for_a_credential( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The claim `deps` is sold on: a stranger runs it in the first minute with no account. + + Asserted by making the credential lookup explode. `--verify` must reach the lock files without + ever touching it, so this fails loudly if the flag ever stops gating that call. + """ + import argparse + import io + import subprocess + + from hullwork import cli + from hullwork.config import Settings + + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) # noqa: S603, S607 + (tmp_path / "hullwork.yml").write_text( + "project: p\ngit: {provider: forgejo, repo: o/r}\n" + "autofix: {agent: none, gates: [tests, human-merge]}\n" + 'tests: "pytest"\ntest_path: tests\n' + "runtime: {base: python-3.12, install: none, dependencies: []}\n" + ) + + def explode(*_a: object, **_k: object) -> object: + raise AssertionError("--verify asked for a credential") + + monkeypatch.setattr(cli, "_forge_for_opening", explode) + + with pytest.raises(cli.CommandError) as refused: + cli._cmd_deps( + argparse.Namespace( + checkout=str(tmp_path), verify=True, fix=False, open=False, into=str(tmp_path) + ), + Settings(), + io.StringIO(), + ) + + # It got as far as looking for lock files, which is past every point a credential could have + # been wanted. + assert "no lock file" in str(refused.value) + + +def test_open_refuses_a_checkout_with_no_remote_to_name_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A coordinate cannot be guessed from a directory name, and a wrong one opens elsewhere.""" + from hullwork import cli + from hullwork.config import Settings + + monkeypatch.setattr(cli, "make_code_forge", lambda _s: FakeForge()) + + with pytest.raises(cli.CommandError) as refused: + cli._forge_for_opening(Settings(), tmp_path) + + assert "origin" in str(refused.value) + + +# --- what the body says, which is the part a person acts on ----------------------------------- + + +def _body(advisories: tuple[osv.Advisory, ...] = ADVISORIES) -> str: + return evidence.dependency_pull_request_body(_clean(), advisories) + + +def test_the_claim_is_dr_0016s_wording_and_never_the_word_safe() -> None: + """The one sentence that must not drift, asserted on the rendered text. + + A green pull request is the easiest place in this product to overclaim: the reviewer is being + asked to merge, and *safe* is the word they will read into anything vaguer. + """ + body = _body() + + assert "your suite passed before this change and passes after it" in body + # The word is allowed to appear — twice, in fact — and only ever inside a denial. Enumerating + # the two permitted sentences would be a test that has to be edited whenever either is + # reworded, which is how a guard comes to be maintained into uselessness. The rule is that + # nothing here *asserts* safety, so that is what is asserted. + for index in _every(body, "safe"): + before = body[max(0, index - 60):index] + assert "not" in before, f"'safe' claimed rather than denied, after: …{before}" + + +def test_the_claim_is_the_same_function_the_terminal_prints() -> None: + """The page and the pull request cannot come to disagree if there is one author. + + Not a second rendering that happens to match today: the body quotes `Answer.says`, so a change + to the wording changes both or neither. + """ + answer = _clean() + + assert answer.says in evidence.dependency_pull_request_body(answer, ADVISORIES) + + +def test_the_body_carries_the_advisory_its_id_and_where_to_read_it() -> None: + body = _body() + + assert "GHSA-462w-v97r-4m45" in body + assert "https://osv.dev/vulnerability/GHSA-462w-v97r-4m45" in body + assert "sandbox escape" in body + + +def test_the_body_carries_both_runs_with_the_suites_own_summary_lines() -> None: + """The command and the exit codes, and what the runner itself said. Not our paraphrase.""" + body = _body() + + assert "pytest -q" in body + assert "248 passed in 31.02s" in body + assert "248 passed in 30.44s" in body + + +def test_the_body_says_what_was_measured_was_your_suite() -> None: + """The sentence that keeps it honest, and the reason a green verdict is not a guarantee.""" + body = _body() + + assert "never exercise this dependency" in body + assert "Nothing here inspected the change itself" in body + + +def test_the_caveat_has_one_author() -> None: + """Item 098's rule, made checkable because this document broke it on its first reading. + + `Answer.says` ends by saying what was measured; the paragraph beneath it used to say the same + thing again in different words, and every assertion in this file passed. What catches that is + not a better assertion about content — it is counting. The phrase belongs to one sentence, and + a second author of it is a body that reads like a program that has lost its place. + """ + body = _body() + + assert len(_every(body, "what was measured")) == 1 + assert len(_every(body, "fixes anything")) == 1 + + +def test_an_upgrade_with_no_advisory_is_still_a_body_a_person_can_read() -> None: + """Not every upgrade worth taking has something published against the version it replaces.""" + body = evidence.dependency_pull_request_body(_clean(), ()) + + assert "your suite passed before this change and passes after it" in body + assert "GHSA" not in body + + +def test_a_suite_that_printed_a_secret_does_not_print_it_here() -> None: + """This text leaves the instance under our own account. Item 027's rule, one caller later.""" + answer = bump.Answer( + bump.Verdict.CLEAN, "jinja2", WAS, TO, + files={"requirements.txt": b"jinja2==2.10.1\n"}, + runs=bump.Runs( + command="pytest -q", + before_exit=0, after_exit=0, + before_summary="ok", + after_summary="248 passed; token=ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ), + ) + + body = evidence.dependency_pull_request_body(answer, ()) + + assert "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" not in body + + +def test_the_body_a_pull_request_gets_is_the_one_the_opener_sends() -> None: + """Rendered once, by the module that owns what a reviewer reads, and handed over whole.""" + forge = FakeForge() + + upgrades.open_them( + forge, repo="o/r", + reports=[_report("jinja2", _clean())], + advisories={"jinja2": ADVISORIES}, + base_sha=BASE, + permitted=True, + ) + + body = str(forge.pulls[0]["body"]) + assert "your suite passed before this change and passes after it" in body + assert "GHSA-462w-v97r-4m45" in body + assert "jinja2" in str(forge.pulls[0]["title"]) + + +def test_what_is_committed_is_what_the_gates_ran_against() -> None: + """Carried on the answer, never re-derived. + + Re-applying the upgrade to work out the diff would run the resolver a second time, and a lock + regenerated twice can differ — a version published in between, a different ordering. Publishing + files that are not the ones the suite passed against is the defect item 045 is named after. + """ + forge = FakeForge() + answer = _clean() + + upgrades.open_them( + forge, repo="o/r", reports=[_report("jinja2", answer)], + advisories={}, base_sha=BASE, permitted=True, + ) + + assert forge.commits[0][2] == ("requirements.txt",) + + +@pytest.mark.parametrize("draft", [True, False]) +def test_it_is_opened_as_a_draft_and_says_so_when_the_forge_disagrees(draft: bool) -> None: + """Constitution §1: nothing merges by itself, and a forge that un-drafts is a finding.""" + forge = FakeForge() + original = forge.open_draft_pull_request + + def answered(*args: object, **kwargs: object) -> ForgePullRequest: + made = original(*args, **kwargs) # type: ignore[arg-type] + return ForgePullRequest( + number=made.number, title=made.title, html_url=made.html_url, draft=draft + ) + + forge.open_draft_pull_request = answered # type: ignore[method-assign] + opened = upgrades.open_them( + forge, repo="o/r", reports=[_report("jinja2", _clean())], + advisories={}, base_sha=BASE, permitted=True, + ) + + assert len(opened) == 1 + + +# --- the first thing a project can refuse (item 187, DR-0019) ---------------------------------- + + +def test_a_project_that_has_not_permitted_it_gets_nothing_opened() -> None: + """**Having a capability is not consenting to the feature it enables.** + + Until DR-0019, declaring an installer and a lock file *was* agreeing to pull requests in your + repository — nobody said so. This is the sentence this product already applies to lanes, *a + policy nobody has read is a policy nobody has agreed to*, pointed at itself. + + Everything that would have been opened is verified green: the refusal is about consent and not + about the evidence, which is why the verification above it still ran. + """ + forge = FakeForge() + reports = [_report(name, _clean(name)) for name in ("aaa", "bbb")] + + opened = upgrades.open_them( + forge, repo="o/r", reports=reports, advisories={}, base_sha=BASE, permitted=False + ) + + assert opened == [] + assert forge.branches == [], "a branch is already a write to somebody's repository" + assert forge.pulls == [] + # And the eligible ones were eligible: this is consent, not a verdict. + assert len(upgrades.eligible(reports)) == 2 + + +def test_forgetting_the_permission_is_a_crash_and_never_an_open() -> None: + """Item 017's rule, and the reason this is a parameter rather than a check at the call site. + + *A guardrail that depends on every caller remembering it is not a guardrail.* This is the only + function in the product that opens anything, so a caller who forgets gets a `TypeError` — not + an unguarded pull request in somebody's repository. + """ + with pytest.raises(TypeError, match="permitted"): + upgrades.open_them( # type: ignore[call-arg] + FakeForge(), repo="o/r", reports=[_report("jinja2", _clean())], + advisories={}, base_sha=BASE, + ) + + +def test_the_permission_is_false_unless_a_project_wrote_it() -> None: + """A permission that arrives switched on is not a permission. + + Every other default in `autofix` is the refusing one — `agent: none`, `unmatched: human` — and + this one joins them. + """ + from hullwork.manifest import parse_manifest + + silent = parse_manifest( + "project: p\ngit: {provider: forgejo, repo: o/r}\ntests: pytest\n" + ) + asked = parse_manifest( + "project: p\ngit: {provider: forgejo, repo: o/r}\ntests: pytest\n" + "autofix: {open_upgrades: true}\n" + ) + + assert silent.autofix.open_upgrades is False + assert asked.autofix.open_upgrades is True diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index e8aac71..ccc6450 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -19,7 +19,7 @@ from hullwork.config import get_settings from hullwork.db import make_engine, make_session_factory from hullwork.main import app -from hullwork.models import Delivery, Item, Project +from hullwork.models import Delivery, Item, Lane, Project from hullwork.security import generate_token, hash_token from hullwork.webhooks import MAX_BODY_BYTES, MAX_JSON_DEPTH, json_depth @@ -57,6 +57,15 @@ } +#: **The recorded envelope, not one written for this test** (item 191). It carries its own +#: provenance in a `_comment`: read from Sentry's `app_platform_event.py` on 2026-07-26, and it is +#: the same file `test_normalise` parses. Until this item it had only ever been handed to +#: `sentry.parse` directly — never posted at the door, which is the half that was never measured. +SENTRY_PAYLOAD = json.loads( + (ROOT / "tests/fixtures/webhook-sentry-event-alert.json").read_text(encoding="utf-8") +) + + @pytest.fixture def token() -> str: return generate_token() @@ -151,15 +160,83 @@ def test_an_unknown_provider_is_refused(client: TestClient, token: str) -> None: assert _post(client, token, provider="rollbar").status_code == 404 -def test_the_sentry_route_says_it_is_not_enabled_rather_than_failing_oddly( +def test_a_sentry_delivery_with_a_valid_token_becomes_an_item( + client: TestClient, token: str, tmp_path: Path +) -> None: + """**The route this refused for wanting a better guarantee** (item 189, operator's choice). + + It answered `501` because verifying Sentry's HMAC means holding its client secret reversibly — + correct, and it was written as *reversible secret or nothing*. There is a third option: + **GlitchTip cannot sign at all**, so the token in the URL has been the credential since M1, and + Sentry gets that same credential verified the same way. The route was declining to offer a + guarantee better than the one the only working provider gets. + """ + response = _post(client, token, payload=SENTRY_PAYLOAD, provider="sentry") + + assert response.status_code == 200 + assert response.json()["status"] == "accepted" + with _session(tmp_path) as db: + assert db.query(Delivery).count() == 1 + item = db.query(Item).one() + assert item.title.startswith("TypeError") + # **The fields that decide something**, not only the one that displays. + # + # This delivery is red, and the interesting part is *why*: the recorded culprit is + # `app.views.checkout in process_payment`, and this manifest declares `red: ["payment"]`. + # GlitchTip's fixture describes a different error (`app.cart in total`) and comes out green, + # so the two are not comparable and both are right. + # + # **The lane alone cannot say that**, which is the trap this assertion was nearly written + # into: red is *also* what an item whose culprit never arrived would get, because anything + # unclassified defaults to red. So the reason is what is asserted — it names the rule that + # fired, and a lost culprit would give the default's wording instead. + assert item.lane is Lane.RED + assert item.lane_reason and "payment" in item.lane_reason, ( + f"the culprit did not reach triage; the lane defaulted: {item.lane_reason!r}" + ) + # **Exact, not a substring.** CodeQL flags `"sentry.io" in url` as incomplete URL + # sanitisation, and as a *security* finding on a test assertion it is false — but the + # assertion is genuinely weaker than it should be: `https://sentry.io.evil.example/` would + # satisfy it. That is item 161's real defect, in a test that checks for it. + assert item.permalink and item.permalink.startswith("https://sentry.io/") + + +def test_a_wrong_token_looks_the_same_on_both_routes( + client: TestClient, token: str +) -> None: + """**Status and body**, because a difference between them confirms a provider by probing. + + Item 122 already established that shape for the page: a wrong token's `404` is byte-identical + to an unknown path's. The same reasoning applies to two providers on one door — if Sentry's + refusal read differently from GlitchTip's, the door would answer a question nobody is entitled + to ask. + """ + wrong = "b" * 43 + + glitchtip = _post(client, wrong) + sentry = _post(client, wrong, payload=SENTRY_PAYLOAD, provider="sentry") + + assert glitchtip.status_code == sentry.status_code + assert glitchtip.text == sentry.text + + +def test_the_signature_header_is_never_read_as_authentication( client: TestClient, token: str ) -> None: - """Its adapter exists, but verifying its HMAC needs the secret in reversible form — a storage - decision not yet made. Saying so beats a confusing 401.""" - response = _post(client, token, provider="sentry") + """**(B) is weaker than (C) and must not pretend otherwise.** - assert response.status_code == 501 - assert "GlitchTip" in response.text + Sentry does sign, and this ignores it: what authenticates is the token in the path. A delivery + carrying a plainly bogus signature is accepted, because the signature is not what was checked — + and a reader of this test learns that the HMAC is unverified rather than inferring it from a + silence. + """ + response = client.post( + f"/webhooks/sentry/{SLUG}/{token}", + json=SENTRY_PAYLOAD, + headers={"sentry-hook-signature": "0" * 64}, + ) + + assert response.status_code == 200 # --- limits ---------------------------------------------------------------------------------- diff --git a/tests/test_what_it_can_do_for_you.py b/tests/test_what_it_can_do_for_you.py new file mode 100644 index 0000000..a93b10d --- /dev/null +++ b/tests/test_what_it_can_do_for_you.py @@ -0,0 +1,323 @@ +"""What this can do for your project, and what it cannot. Item 186. + +The operator's framing: **Hullwork is modular — a developer turns features on and off, and each +feature has its limitations.** The first half already existed in `hullwork.yml`; the second half +existed nowhere, so every limitation was found by walking into it. + +**The tests that matter here are about the two halves not blending.** A feature's *needs* are +checkable and either met or not; its *limits* are true whatever the answer, and both have to be +printed either way — because a limit you meet after adopting something is a limit you found the +expensive way. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hullwork import features +from hullwork.manifest import Manifest, parse_manifest + +MANIFEST = """ +project: p +git: {provider: github, repo: o/r} +tests: "pytest" +test_path: tests +runtime: {base: python-3.12, install: pip, dependencies: [requirements.txt]} +""" + +#: The shape `propose` writes when a project's CI hides its install step, and the shape a project +#: that brings its own image has. Item 182 measured a **false verdict** produced under it. +OWN_IMAGE = """ +project: p +git: {provider: github, repo: o/r} +tests: "pytest" +test_path: tests +runtime: {base: python-3.12, install: none, dependencies: []} +""" + + +def _checkout( + manifest_text: str | None = MANIFEST, + *, + paths: tuple[str, ...] = ("requirements.txt", "src/app.py"), + configured: tuple[str, ...] = (), +) -> features.Checkout: + manifest: Manifest | None = parse_manifest(manifest_text) if manifest_text else None + return features.Checkout( + paths=paths, manifest=manifest, configured=frozenset(configured) + ) + + +def _named(answers: list[features.Answer], name: str) -> features.Answer: + return next(a for a in answers if a.feature.name == name) + + +# --- the half that had no home anywhere -------------------------------------------------------- + + +def test_every_feature_declares_what_it_cannot_do() -> None: + """**Never empty**, and this is the structural half of the operator's framing. + + A feature with no limits reads as one that has none. Until this module that was true of all of + them, and it was false of every single one — the limits existed and lived in docstrings, in + decision records, and in what a person found out by running it. + """ + for feature in features.FEATURES: + assert feature.limits, f"{feature.name} declares no limits, which is never true" + assert feature.needs, f"{feature.name} declares no needs" + for need in feature.needs: + assert need.fix, f"{feature.name}: a need with no way to satisfy it is a dead end" + + +def test_the_limits_are_printed_whether_or_not_the_feature_is_available() -> None: + """The whole point. A feature you *can* have is the one where nobody thinks to look.""" + available = features.examine(_checkout()) + unavailable = features.examine(_checkout(OWN_IMAGE)) + + said = " ".join(features.lines(available)) + also = " ".join(features.lines(unavailable)) + + for text in (said, also): + assert "What is measured is **your suite**" in text + assert "the image has to be refreshed" in text + + +# --- what it says about the case that produced a false verdict --------------------------------- + + +def test_a_project_that_brings_its_own_image_is_told_verification_cannot_serve_it() -> None: + """Item 182's finding, answered before anybody pays for it. + + With `install: none` the image is `runtime.base` exactly as it comes and nothing is installed + from a lock file, so rewriting a pinned version changes nothing the suite would run against. + Measured against a base carrying `jinja2 3.0.0` and a checkout pinning `2.4.1`: a verdict + reading *your suite passed before this change and passes after it*, about a version that was + never installed. + + `install: none` is the **default**, and DR-0007 makes *the project brings its own image* the + primary path — so this is most projects, and the answer has to arrive before the containers do. + """ + answer = _named(features.examine(_checkout(OWN_IMAGE)), "dependency verification") + + assert not answer.available + missing = " ".join(need.what for need in answer.missing) + assert "installer that reads the file your versions are pinned in" in missing + fix = " ".join(need.fix for need in answer.missing) + assert "runtime.install" in fix and "runtime.dependencies" in fix + + +def test_a_project_whose_image_hullwork_builds_is_told_it_can() -> None: + """The other side of the same answer: a report that says no to everything is not a report.""" + answer = _named(features.examine(_checkout()), "dependency verification") + + assert answer.available + assert answer.missing == () + + +def test_a_checkout_with_no_manifest_is_told_which_command_writes_one() -> None: + """The commonest first contact there is, and the one where a refusal has to end in a verb.""" + answer = _named(features.examine(_checkout(None)), "dependency verification") + + assert not answer.available + assert any("propose" in need.fix for need in answer.missing) + # And the report still works: a lock file is a fact about the checkout, not about the manifest. + assert _named(features.examine(_checkout(None)), "dependency report").available + + +def test_every_unmet_need_is_listed_and_not_just_the_first() -> None: + """A reader who fixes one thing and runs this again to find a second is doing the work this + command exists to save them.""" + answer = _named(features.examine(_checkout(None, paths=())), "dependency verification") + + assert len(answer.missing) >= 2 + + +# --- the rules it runs under ------------------------------------------------------------------- + + +def test_a_credential_is_read_for_whether_it_is_set_and_never_for_its_value() -> None: + """This is the command somebody runs before trusting the product with anything. + + It says *needs a model credential, and none is configured* while holding none — which is what + lets it be run by somebody who has configured nothing at all, and what stops it becoming a + place a secret can be printed. + """ + with_key = features.examine(_checkout(configured=(features.MODEL_KEY,))) + without = features.examine(_checkout()) + + assert _named(with_key, "fixing an upgrade that breaks your suite").missing != () + listed = " ".join( + need.what for need in _named(without, "fixing an upgrade that breaks your suite").missing + ) + assert "model credential" in listed + # The whole surface, checked for a value that was never handed to it. + assert "sk-" not in " ".join(features.lines(with_key)) + + +def test_nothing_it_reads_requires_a_daemon_a_socket_or_a_forge( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Asserted by making every route out explode. + + The precedent is `projects lanes --checkout .`, which prints this instance's policy against a + tree with no credential of any kind — *"a policy nobody has read is a policy nobody has agreed + to"*. A capability report that quietly opened a socket would be a different command wearing + this one's promise. + """ + import socket + import subprocess + + def forbidden(*_a: object, **_k: object) -> None: + raise AssertionError("features opened something it may not") + + monkeypatch.setattr(socket, "socket", forbidden) + monkeypatch.setattr(subprocess, "run", forbidden) + monkeypatch.setattr(subprocess, "Popen", forbidden) + + said = features.lines(features.examine(_checkout())) + + assert said + + +def test_the_features_a_checkout_cannot_answer_are_named_rather_than_guessed() -> None: + """Whether a forge answers, whether a tracker is reachable, whether a dispatcher holds the + lease — a checkout cannot know any of it, and a report that pretended to would be worse than + one that says whose question it is.""" + assert features.INSTANCE_SHAPED + reported = {feature.name for feature in features.FEATURES} + for named in features.INSTANCE_SHAPED: + assert named not in reported, f"{named} is claimed here and cannot be answered here" + + +def test_it_writes_nothing_at_all(tmp_path: Path) -> None: + """It is a reading of what you already have. A report that edited a manifest to make itself + truer would be the worst possible version of this command.""" + before = sorted(p.name for p in tmp_path.iterdir()) + + features.lines(features.examine(_checkout())) + + assert sorted(p.name for p in tmp_path.iterdir()) == before + + +# --- the third answer (item 187, DR-0019) ------------------------------------------------------ + + +def test_a_feature_a_project_has_not_permitted_reads_as_a_decision() -> None: + """**Not `no`.** That would report a choice somebody made as a part that is missing, which is + the one way this report could insult its reader. + + Everything the feature needs is here — the lock file, the manifest, the installer, the + credential, the remote. What is absent is the project's yes. + """ + everything = _checkout(configured=(features.CODE_TOKEN, "origin")) + + answer = _named(features.examine(everything), "opening the upgrades that pass") + + assert answer.available, "the capability is all there" + assert not answer.permitted + said = " ".join(features.lines([answer])) + assert "[not permitted here]" in said + assert "this project has not permitted it" in said + assert "open_upgrades: true" in said + + +def test_the_permission_granted_reads_as_yes() -> None: + """The other side: a project that said yes gets the plain answer and no lecture.""" + permitted = features.Checkout( + paths=("requirements.txt",), + manifest=parse_manifest(MANIFEST.rstrip() + "\nautofix: {open_upgrades: true}\n"), + configured=frozenset((features.CODE_TOKEN, "origin")), + ) + + answer = _named(features.examine(permitted), "opening the upgrades that pass") + + assert answer.available and answer.permitted + said = " ".join(features.lines([answer])) + assert "[yes]" in said + assert "not permitted" not in said + + +def test_a_missing_capability_still_reads_as_no_even_when_unpermitted() -> None: + """Two different absences, and the more fundamental one wins the headline. + + A project with no credential *and* no permission is told `no` — because *not permitted here* + would suggest that granting it would be enough, and it would not. + """ + neither = _checkout() + + answer = _named(features.examine(neither), "opening the upgrades that pass") + + assert not answer.available + assert "[no]" in " ".join(features.lines([answer])) + + +def test_only_what_writes_to_a_repository_asks_for_permission() -> None: + """DR-0019's bound, asserted structurally so the switchboard cannot grow quietly. + + *Could a project have the capability, understand the feature, and rationally not want it?* If + wanting is implied by having, there is no switch — and every feature that writes nothing to + somebody's repository is in that class. A second `permits` entry appearing here is Renovate's + complaint nº2 arriving as a feature, which DR-0018 refuses by name. + """ + asking = [f.name for f in features.FEATURES if f.permits] + + assert asking == ["opening the upgrades that pass"], ( + "a new permission was added; DR-0019's rule has to be applied to it in writing first" + ) + + +# --- the two paths compose (item 188, DR-0007) ------------------------------------------------- + +#: A project's own image, plus the one line that refreshes its dependencies on top of it. Legal +#: since DR-0007 was built and written down nowhere until item 188. +OWN_IMAGE_REFRESHED = """ +project: p +git: {provider: github, repo: o/r} +tests: "pytest" +test_path: tests +runtime: + base: ghcr.io/acme/ci-base:2026.7 + install: "pip install -r requirements.txt" + dependencies: [requirements.txt] +""" + + +def test_an_image_hullwork_did_not_build_is_served_when_it_is_refreshed() -> None: + """**The operator's directive answered without adding a stack** (item 188). + + Item 182 measured that verification served only projects whose image Hullwork builds — DR-0007's + path (A), which that decision demoted to *sugar*. Going to design a table of environment + strategies found none was needed: `base` takes any image and `install` takes the project's own + command, so *your image plus one line* was always legal. + + Measured against a real daemon on 2026-08-09, asking both containers rather than reading the + report: `before -> jinja2 3.0.0` (what the project's own image carries) and + `after -> jinja2 3.1.6` (the upgrade, actually installed). + """ + answer = _named(features.examine(_checkout(OWN_IMAGE_REFRESHED)), "dependency verification") + + assert answer.available, "a base Hullwork did not build is still measurable when refreshed" + + +def test_the_limit_no_longer_says_a_project_with_its_own_image_is_not_served() -> None: + """It said exactly that, and item 188 measured it false. + + A limit that overstates is worse than none: this one would have sent every project on DR-0007's + **primary** path away from a feature that serves them, and it would have read as honesty. + """ + said = " ".join(features.lines(features.examine(_checkout(OWN_IMAGE_REFRESHED)))) + + assert "not served" not in said + assert "does not mean Hullwork must build your image" in said + + +def test_the_remedy_tells_them_to_keep_their_own_base() -> None: + """The difference between one line and a rebuild from scratch, said where they will read it.""" + answer = _named(features.examine(_checkout(OWN_IMAGE)), "dependency verification") + + fix = " ".join(need.fix for need in answer.missing) + assert "Keep your own image" in fix diff --git a/tests/test_what_the_closed_items_owed.py b/tests/test_what_the_closed_items_owed.py index 1bad800..dba2a18 100644 --- a/tests/test_what_the_closed_items_owed.py +++ b/tests/test_what_the_closed_items_owed.py @@ -364,6 +364,10 @@ def test_the_doctor_does_not_claim_a_clean_host_it_could_not_look_at() -> None: #: `docker compose build` had made it in some earlier session and nothing recorded the dependency, #: so the test failed rather than skipped, with a message about the gateway rather than the image. #: +#: **And the instruction went stale under it** (item 191, 2026-08-09). `docker-compose.yml` now pins +#: a published image and has no build stage — its own comment says to *add* one — so +#: `docker compose build` exits 0 and produces nothing. Measured while chasing this exact skip. +#: #: A skip with a reason, not a build: building it here would put minutes into an unrelated test run #: and hide the same gap. Saying what is missing is the honest answer, and the error that exposed #: this now prints Docker's own words, which is how it was diagnosed in one read. @@ -385,7 +389,7 @@ def test_the_doctor_does_not_claim_a_clean_host_it_could_not_look_at() -> None: capture_output=True, timeout=30, check=False, ).returncode != 0, - reason="needs the hullwork:dev image; build it with `docker compose build`", + reason="needs the hullwork:dev image; build it with `docker build --tag hullwork:dev .`", )