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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ returns a verdict with the run attached. The oracle changes per signal; the mach
|---|---|---|
| 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 |
| a static finding | *this could be exploited*, or *this code is dead* | a test naming the hostile input — or removing the code and running your suite, where coverage proves the line ran |

**The first two rows are in a release** — the second since `0.1.0a8`, and it says no more often than
yes. The third does not exist.
Expand Down
2 changes: 1 addition & 1 deletion docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ each claim is part of the claim.
> |---|---|---|
> | 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 | **released in `0.1.0a8`** — `hullwork deps`, and it refuses far more than it verifies |
> | a static finding | a test naming the hostile input | does not exist |
> | a static finding | a test naming the hostile input, or removing the code under coverage | **does not exist** — its shape is decided (DR-0020) and its build is not ordered |
>
> The second row is work items 172–180. Until `0.1.0a8` this page named its state and deliberately
> **not its command**, because a command a reader cannot run is an invitation to type it and be told
Expand Down
2 changes: 1 addition & 1 deletion docs/what-hullwork-is.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Every signal Hullwork accepts arrives from a tool that **asserts something and p
|---|---|---|---|
| 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 |
| a static finding | CodeQL, Opengrep | *this could be exploited*, or *this code is dead* | a test naming the hostile input, or removing the code under coverage |

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.
Expand Down
56 changes: 39 additions & 17 deletions hullwork/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2106,10 +2106,8 @@ def _cmd_status(
findings = credentials.audit(
session, make_permission_reader(settings), probe=_scope_probe(settings)
)
compose = DEFAULT_COMPOSE_FILE if DEFAULT_COMPOSE_FILE.exists() else None
gaps = doctor.environment_gaps(
settings, env_file=DEFAULT_ENV_FILE, compose_file=compose
)
env_file, compose = where_the_deployment_files_are(settings)
gaps = doctor.environment_gaps(settings, env_file=env_file, compose_file=compose)
payload = report.as_dict()
payload["dispatcher_loop"] = {
"state": loop_state,
Expand Down Expand Up @@ -2348,6 +2346,36 @@ def _cmd_status(
DEFAULT_COMPOSE_FILE = Path("docker-compose.yml")


def where_the_deployment_files_are(
settings: Settings,
*,
env_file: str | None = None,
compose_file: str | None = None,
) -> tuple[Path, Path | None]:
"""The env file and the compose file `environment_gaps` should read, for every command.

**One question, one answer** (item 194, and item 193 the same day for the same reason). Item 144
added these settings so a containerised instance could point the check at the host's files —
inside a container the working directory holds neither, so it silently never ran on any real
deployment. That fix reached `doctor` and neither of the two call sites `status` uses, so on the
live instance the configured path was set, the file was mounted at it, and `status` printed *not
checked: no environment file at `.env`* — the default it never replaced.

Precedence is a person, then the machine, then the default: `--env-file` is the only place
somebody names the file by hand, and somebody standing in front of the machine outranks how it
was configured.

The compose falls back to the default **only when it exists**, because `None` there means *no
compose to compare against*, which is a different fact from *a compose that passes nothing on*.
"""
resolved_env = Path(env_file or settings.deployment_env_file or DEFAULT_ENV_FILE)

named = compose_file or settings.deployment_compose_file
if named:
return resolved_env, Path(named)
return resolved_env, DEFAULT_COMPOSE_FILE if DEFAULT_COMPOSE_FILE.exists() else None


def _cmd_doctor(
args: argparse.Namespace, session: Session, settings: Settings, out: TextIO
) -> int:
Expand All @@ -2361,18 +2389,12 @@ def _cmd_doctor(
Exit code is the answer, as everywhere else — and an `unknown` never sets it. That is item 073's
lesson: a warning wired into an exit code with no action available to clear it is not a signal.
"""
# **Configuration before the working directory** (item 144). The flags still win, because
# somebody running this from a host shell knows where the files are. What changed is the
# fallback: it used to be the working directory, which inside a container holds neither file, so
# the check silently never ran on any real deployment. Now the instance can say where they are.
env_file = Path(
args.env_file or settings.deployment_env_file or DEFAULT_ENV_FILE
# **Configuration before the working directory** (item 144), and now in one place for all three
# commands (item 194) — this was the only one that had it, which is why `status` was reporting
# `not checked` on an instance that had configured everything the message asked for.
env_file, compose_file = where_the_deployment_files_are(
settings, env_file=args.env_file, compose_file=args.compose_file
)
named_compose = args.compose_file or settings.deployment_compose_file
if named_compose:
compose_file: Path | None = Path(named_compose)
else:
compose_file = DEFAULT_COMPOSE_FILE if DEFAULT_COMPOSE_FILE.exists() else None

code_forge = make_code_forge(settings)
# The **ingest** credential for the inventory check: asking whether an issue still exists is a
Expand Down Expand Up @@ -2612,8 +2634,8 @@ def _report_environment(settings: Settings, out: TextIO) -> list["doctor.Finding
`tracker configured: false` is true of this process and can be false of the machine, and that
sentence is what sent somebody looking in the wrong place for a day.
"""
compose = DEFAULT_COMPOSE_FILE if DEFAULT_COMPOSE_FILE.exists() else None
gaps = doctor.environment_gaps(settings, env_file=DEFAULT_ENV_FILE, compose_file=compose)
env_file, compose = where_the_deployment_files_are(settings)
gaps = doctor.environment_gaps(settings, env_file=env_file, compose_file=compose)
if not gaps:
return []
print("\n Configuration that did not arrive:", file=out)
Expand Down
131 changes: 127 additions & 4 deletions hullwork/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ def fetch_context(
tracker: Tracker | None = None,
limit: int = 20,
recheck_after: int = 600,
forge: Forge | None = None,
) -> int:
"""Ask the tracker for the full error behind each item that has not got one yet (item 036).

Expand Down Expand Up @@ -403,14 +404,16 @@ def fetch_context(
# `finally`, so a tracker that raises mid-pass does not cost the earlier items their
# backoff.
try:
if _fetch_one(session, tracker, item):
if _fetch_one(session, tracker, item, forge):
fetched += 1
finally:
session.commit()
return fetched


def _fetch_one(session: Session, tracker: Tracker, item: Item) -> bool:
def _fetch_one(
session: Session, tracker: Tracker, item: Item, forge: "Forge | None" = None
) -> bool:
"""Bring one item's context up to date. Returns whether anything was fetched.

Extracted from the loop by item 083 so there is **one** place that commits. Five `continue`
Expand Down Expand Up @@ -458,12 +461,55 @@ def _fetch_one(session: Session, tracker: Tracker, item: Item) -> bool:
if event is None:
item.context_error = "the tracker no longer has this issue"
return False
first_sample = not has_sample
_store_context(session, item, event)
# **The half that reaches an issue already filed** (item 196). A body is written once, at
# creation, and on the live instance every issue predated its own enrichment by days — so
# putting the evidence in the body fixes the next issue and none of the existing ones.
#
# **Once per item, and `first_sample` is not what makes that true.** Measured by mutation:
# removing it changes nothing, because with a sample already stored both branches above return
# before reaching `_store_context`, so this line is reached at most once in an item's life.
# The term stays as a second lock on a door that is already shut — cheap, and the day
# `_fetch_one` learns to re-fetch, the alternative is a comment on every pass until somebody
# mutes the repository. It is defence, and it is labelled as defence rather than sold as the
# guarantee.
if first_sample and forge is not None and item.forge_issue_ref:
_post_the_evidence(session, forge, item)
item.context_error = None
_relane_now_that_we_know_where(session, item, event)
return True


def _post_the_evidence(session: Session, forge: "Forge", item: Item) -> None:
"""Comment the evidence onto an issue that was filed before it arrived.

A failure here is logged and swallowed: enrichment is worth having whether or not the forge is
answering this minute, and the fetch that produced it must not be lost to a comment that could
not be posted. What is lost is the comment, and the body of the next issue still carries it.
"""
project = item.project
stored = (
session.query(FetchedEvent)
.filter(FetchedEvent.item_id == item.id)
.order_by(FetchedEvent.id.desc())
.first()
)
if stored is None: # pragma: no cover - defensive; the caller has just stored one
return
body = "\n".join(
["Hullwork has the error's detail now, which arrived after this issue was filed.",
*_evidence_lines(stored, item.permalink)]
)
try:
forge.comment(project.repo, int(str(item.forge_issue_ref).lstrip("#")), body)
except (ForgeError, ValueError) as exc:
log.warning(
"could not post the evidence onto the issue",
extra={"item": item.id, "issue": item.forge_issue_ref, "error": str(exc)},
)


def _relane_from_stored_sample(session: Session, item: Item) -> bool:
"""Re-decide a lane using a sample fetched before item 070 existed.

Expand Down Expand Up @@ -843,7 +889,7 @@ def sweep(
# Last: it is the only step that is pure enrichment. An item is filed and reconciled
# whether or not the tracker answers, and a tracker having a bad minute must not delay
# the work that has a human waiting on it.
fetched = fetch_context(session, tracker, recheck_after=recheck_after)
fetched = fetch_context(session, tracker, recheck_after=recheck_after, forge=forge)
# After enrichment, and last of all: DR-0011. The inventory is how an issue that never got a
# webhook — because the tracker speaks once per issue for its whole life — arrives at all.
# It goes here rather than in the dispatcher because it needs the ingest credential and
Expand Down Expand Up @@ -1004,7 +1050,83 @@ def _file(session: Session, forge: Forge, project: Project, item: Item) -> bool:
return True


def _issue_body(item: Item) -> str:
#: How many frame locations an issue shows. `brief.py` shows the same number to the agent, and for
#: the same reason: the innermost are the defect and the rest is the framework that called it.
MAX_FRAMES_IN_AN_ISSUE = 8

#: Said in the artefact, every time, rather than in a document somebody would have to go and find.
#: Item 196's second criterion: what an issue leaves out is a decision the instance makes and can
#: state, not a default nobody chose.
WHAT_THE_ISSUE_LEAVES_OUT = (
"Locations only — **no variables**, and no source lines. A captured variable can hold a "
"credential and this body is republished into a repository; source lines go stale as the code "
"moves. The link above has both, from the tracker, under whatever access it already enforces."
)


def _where_it_happened(frames: list[dict[str, Any]]) -> list[str]:
"""Frame locations, innermost last, as `module.function:lineno`.

Deliberately not `brief.py`'s renderer, which is right for the agent and wrong here: that one
includes `context_line`, because a model writing a reproducing test needs the failing source in
front of it. A person has the link.
"""
said = []
for frame in frames[-MAX_FRAMES_IN_AN_ISSUE:]:
where = frame.get("module") or frame.get("filename") or frame.get("abs_path") or "?"
function = frame.get("function")
line = frame.get("lineno")
said.append(f"{where}.{function}:{line}" if function else f"{where}:{line}")
return said


def _evidence_lines(event: "FetchedEvent | None", permalink: str | None) -> list[str]:
"""What Hullwork knows about the error, for the person who has to decide about it.

**This existed and went only to the agent** (item 196). `brief.py` renders frames, the culprit
and whether locals were captured; `_issue_body` read the `Item` row alone and never touched the
enrichment sitting one table away. Measured on the live instance: two items whose issues carried
four rows of table and no link, untouched for four days, about the page crashing in production.
"""
if event is None:
return [
"",
"The error's detail has **not arrived yet** — the tracker has not been asked, or could "
"not answer. That is different from there being none, and this line is here so it "
"cannot be read as the second.",
*(["", f"The error: {permalink}"] if permalink else []),
]

said = ["", "## The error"]
if event.message:
said += ["", f"**{event.exception_type or 'Error'}**: {event.message}"]
elif event.exception_type:
said += ["", f"**{event.exception_type}**"]
if event.culprit:
said.append(f"In `{event.culprit}`.")

facts = [
(name, value)
for name, value in (
("Release", event.release),
("Environment", event.environment),
("When", event.occurred_at.isoformat() if event.occurred_at else None),
)
if value
]
if facts:
said += ["", "| | |", "|---|---|", *[f"| {name} | {value} |" for name, value in facts]]

where = _where_it_happened(event.frames or [])
if where:
said += ["", "Where it happened, innermost last:", "", *[f"- `{one}`" for one in where]]
if permalink:
said += ["", f"The error, in full: {permalink}"]
said += ["", WHAT_THE_ISSUE_LEAVES_OUT]
return said


def _issue_body(item: Item, event: "FetchedEvent | None" = None) -> str:
lines = [
"Reported by Hullwork from a production error.",
"",
Expand Down Expand Up @@ -1032,6 +1154,7 @@ def _issue_body(item: Item) -> str:
"",
f"Red lane: an agent will never touch this. Reclassify it in {where} if that is wrong.",
]
lines += _evidence_lines(event, item.permalink)
lines += ["", marker_for(item.fingerprint), ""]
return "\n".join(lines)

Expand Down
21 changes: 20 additions & 1 deletion hullwork/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,33 @@
HUMAN_MERGE = "human-merge"


#: What an unknown field's refusal adds, and it is a **hint rather than a diagnosis** (item 195).
#: `SCHEMA_VERSION`'s docstring promised a manifest from the future would be refused clearly instead
#: of producing this wall — but that path needs the file to *declare* the higher version, and
#: `version:` is optional and nobody writes it. So the wall is what a project adopting a new field
#: actually gets, and it says nothing about the possibility that the field is simply newer than the
#: binary reading it.
#:
#: Added beside the field and never in place of it: **most unknown fields are typos**, and sending
#: somebody to upgrade over a missing letter is a worse answer than the wall was.
_MAY_BE_NEWER = (
"One of these may be a field from a newer Hullwork rather than a mistake: this build "
"understands schema {version}. If your project needs it, upgrade Hullwork; if it is a typo, "
"the name and value above are what it read."
)


class ManifestError(Exception):
"""The manifest is not usable. Carries every problem found, not just the first."""

def __init__(self, source: str, problems: list[str]) -> None:
self.source = source
self.problems = problems
listed = "\n".join(f" {problem}" for problem in problems)
super().__init__(f"{source} is not a valid Hullwork manifest:\n{listed}")
said = f"{source} is not a valid Hullwork manifest:\n{listed}"
if any("Extra inputs are not permitted" in problem for problem in problems):
said += "\n\n" + _MAY_BE_NEWER.format(version=SCHEMA_VERSION)
super().__init__(said)


class _Strict(BaseModel):
Expand Down
Loading
Loading