Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ read is a policy nobody has agreed to.
its suite runs against a blank one per phase. That path has been exercised by **one** project, and
`alembic upgrade head` in its test command is what made it necessary.

**A verified upgrade can be opened from the page, and only by a person.** As of item 245: an upgrade
this instance verified green — applied in a clone, your suite run before and after — carries the files
it passed with and the commit it ran at, so pressing the control on its row opens a draft pull request
holding exactly that, rooted at exactly that commit. Nothing is opened on a clock: this instance
verifies on its own schedule and **never** opens by itself (DR-0026).

Two things that follow, and both are refusals you will meet:

- **The half that renders the page cannot push.** It writes down that you asked; the other process,
the one with no socket and the code credential, opens it on its next turn. So the pull request
appears seconds later and the page says which state the row is in rather than pretending the click
was the act.
- **Your manifest outranks the button.** `autofix.open_upgrades` is `false` by default, and while it
is, the page tells you how many passed and that none can be opened. Having the credential is not the
same as having agreed, and the report is what there is to act on either way.

## What does not exist yet

- **Sentry's webhook route is enabled since `0.1.0a8`, and its signature is not verified.** It is
Expand Down
8 changes: 8 additions & 0 deletions hullwork.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ autofix:
# does not exist yet, and it refuses without a code credential the live instance does not hold.
agent: claude-code
sandbox: docker
# Permission, not capability (DR-0019). Turned on 2026-08-13 by the operator, once item 245 made
# the request a person's act: a verified-green upgrade carries the files its suite passed with and
# the commit it ran at, and pressing the control on the page opens a draft pull request holding
# exactly that. Nothing here opens on a clock — the instance verifies on its own schedule and
# never opens by itself. What this line says to a contributor reading it is: on this repository,
# a human may ask for that, and every one of them still arrives as a draft nobody but a human
# merges.
open_upgrades: true
lanes:
green:
- typeerror
Expand Down
126 changes: 126 additions & 0 deletions hullwork/advisories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""What OSV has published against what a project pins. DR-0024, item 230.

The half of the product that needs **no model, no write credential and no Docker** — the half an
evaluator can use on their first day — left no trace in a running instance until this: `hullwork
deps` opened no session, stored nothing, and could not even run inside the container.

**What this module is and is not.** It reads, asks and returns; it writes no rows and knows nothing
about pages or clocks. The caller stores the answer, because *when it was asked* is half of it and
that belongs with the row rather than in here.

**The verification half stays where it is.** Applying an upgrade and running a suite needs the
Docker socket, and DR-0005 gives the receiver none. This can say what is published; only the
dispatcher can say whether the fix survives your tests.
"""

from __future__ import annotations

from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from typing import Any, Protocol

from hullwork import dependencies
from hullwork.osv import Finding, Osv


class Tree(Protocol):
"""A listing. **Read-only properties, not attributes**: a `Protocol` declaring a mutable
attribute is invariant, so the forge's own `Tree` — whose `paths` is a `tuple` — does not
satisfy `paths: Sequence[str]` however obviously it does in practice."""

@property
def paths(self) -> Sequence[str]: ...

@property
def truncated(self) -> bool: ...


class Reads(Protocol):
def tree(self, repo: str) -> Tree: ...
def read_file(self, repo: str, path: str) -> str | None: ...


@dataclass(frozen=True)
class Report:
"""What was found, and whether the question was asked at all.

**`asked=False` with a `note` is the answer, not the absence of one.** An advisory list that
silently reads empty when OSV was unreachable says *you are fine* on no evidence, which is the
worst failure this feature can have — and it is the operator's own condition on DR-0024.
"""

asked: bool
pinned: int = 0
findings: list[dict[str, Any]] = field(default_factory=list)
note: str | None = None


def as_rows(found: Sequence[Finding]) -> list[dict[str, Any]]:
"""The findings as the row stores them. One shape, so the page never sees an `Advisory`."""
return [
{
"package": one.dependency.name,
"version": one.dependency.version,
"source": one.dependency.source,
"advisories": [
{"id": a.id, "summary": a.summary, "fixed": list(a.fixed)} for a in one.advisories
],
}
for one in found
if one.advisories
]


def about(repo: str, forge: Reads, ask: Callable[[Sequence[Any]], list[Finding]]) -> Report:
"""Read what this repository pins, and ask what is published against it.

Every failure is a `Report` rather than an exception, and each says which half failed: a forge
that will not list a tree and a database that will not answer are different problems with
different fixes, and *something went wrong* is neither.
"""
try:
listing = forge.tree(repo)
except Exception as exc:
return Report(asked=False, note=f"could not list {repo}: {exc}")

pinned = dependencies.read_lockfiles(
list(listing.paths), lambda path: _read(forge, repo, path)
)
if not pinned:
return Report(
asked=True,
note=(
"nothing here pins a version: no lock file and no `==` in a requirements file, so "
"there is nothing to ask about. A declaration is a range, and a range is not a "
"fact about what your build resolved to"
),
)
try:
found = ask(pinned)
except Exception as exc:
return Report(
asked=False,
pinned=len(pinned),
note=f"read {len(pinned)} pinned version(s) and could not reach OSV: {exc}",
)
return Report(asked=True, pinned=len(pinned), findings=as_rows(found))


def _read(forge: Reads, repo: str, path: str) -> str | None:
"""One file, or `None`. A file that will not read costs its own contribution and no more —
`read_lockfiles` already treats that as *this file said nothing*, which is the honest reading
of a `package-lock.json` the forge refused while `uv.lock` came back fine."""
try:
return forge.read_file(repo, path)
except Exception:
return None


def asking(timeout: float = 20.0) -> Callable[[Sequence[Any]], list[Finding]]:
"""A callable that asks the real OSV and closes after itself."""

def _ask(deps: Sequence[Any]) -> list[Finding]:
with Osv(timeout=timeout) as osv:
return osv.affected(deps)

return _ask
Loading
Loading