froot · Phase 2·

A second loop, same chassis

A code-level review of one PR · the security-patch loop

froot grows a second loop

Replicate the chassis — prove the spine generalizes by running a different loop on it.

froot's charter says a second loop should be nearly free: the durable machinery is identical for every loop, and only the signal, the candidate policy, and a little namespacing make a loop a specialist. This PR tests that claim by adding security-patch — bump a dependency to the lowest version that clears a known advisory — beside the existing dependency-patch loop. The proof is in what did not change. Expand any block to read the surrounding code.

🆕 new this PR ♻️ the spine · unchanged 🛡️ OSV · external truth
2
loops (was 1)
1
new signal source (OSV)
0
lines changed in the state machine
251
tests green
13
review findings folded in

What "a second loop" actually meant

A loop is mostly chassis: a durable schedule, a checkout, the wait on CI, the PR plumbing, the recorded outcome, close-on-red, reconcile. Only three small things make a loop a specialist: the signal (what to propose), the candidate policy (which version), and a little namespacing (so two loops never collide). So security-patch was never going to be a rewrite.

The tour follows the change from the inside out: the Loop seam and how the pure spine stays blind to it; the generalized candidate; the new OSV signal and the version logic that turns it into a target; the loop-aware reconcile; the proof; and a verdict scored on the empty diff in the spine.

The seam1🎬 The prepared seam, and the invariant that keeps it pure

One enum, and a spine that never sees it

src/froot/domain/loop.pysrc/froot/policy/naming.pysrc/froot/workflow/bump_workflow.py

The whole parameterization rides on one enum, modeled on the Ecosystem enum froot already used. Its value is the kebab name that namespaces everything the loop owns — its branches, its label, its workflow ids, its logs.

src/froot/domain/loop.py · 24 lines
src/froot/domain/loop.py24 lines · Python
⋯ 12 lines hidden (lines 1–12)
1"""The maintenance loops froot runs, and how each is namespaced.
2 
3froot's durable chassis is loop-agnostic; only the signal, the candidate policy,
4and a little namespacing make a loop a specialist. Two loops ship:
5:data:`Loop.DEPENDENCY_PATCH` (keep dependencies patched) and
6:data:`Loop.SECURITY_PATCH` (bump dependencies to clear known advisories).
7 
8A loop's *value* is the kebab name that namespaces everything it owns — the
9branch prefix (``froot/<loop>``), the PR label, the workflow ids, and the
10structured-log identity — so two loops never collide on a branch or a workflow
11id even when they touch the same package. A further loop is one more member plus
12its signal and candidate policy; the chassis it runs on does not change.
13"""
14 
15from __future__ import annotations
16 
17from enum import StrEnum
18 
19 
20class Loop(StrEnum):
21 """A maintenance loop froot points at a repo."""
22 
23 DEPENDENCY_PATCH = "dependency-patch"
24 SECURITY_PATCH = "security-patch"

Branches namespace naturally (froot/<loop>/…), so two loops can't push the same branch even for the same package and version. The workflow ids take more care: a deployed dependency-patch loop is already running under its old ids, so the default loop keeps them byte-for-byte and only other loops carry a segment.

src/froot/policy/naming.py · 121 lines
src/froot/policy/naming.py121 lines · Python
⋯ 30 lines hidden (lines 1–30)
1"""Deterministic names — the loop's idempotency keys.
2 
3A bump's head branch and its per-bump Temporal workflow id are pure functions of
4the bump identity (repo + package + target version). Re-running the loop reuses
5the same names, so a duplicate PR or a duplicate in-flight workflow is
6impossible: the loop is idempotent by construction (SPEC: one PR per bump). The
7scan loop's id is a per-repo singleton for the same reason.
8"""
9 
10from __future__ import annotations
11 
12import re
13from typing import TYPE_CHECKING
14 
15from froot.domain.loop import Loop
16from froot.domain.pull_request import BranchName
17 
18if TYPE_CHECKING:
19 from froot.domain.candidate import Candidate
20 from froot.domain.repo import TargetRepo
21 
22# Anything outside the safe set collapses to a single hyphen.
23_UNSAFE = re.compile(r"[^a-z0-9._-]+")
24 
25 
26def _slug(text: str) -> str:
27 """Lowercase and reduce ref/id-unsafe runs to single hyphens."""
28 return _UNSAFE.sub("-", text.lower()).strip("-")
29 
30 
31def _loop_id_segment(loop: Loop) -> tuple[str, ...]:
32 """The workflow-id segment that namespaces a loop.
33 
34 Empty for ``dependency-patch`` so its ids stay byte-for-byte what they were
35 before a second loop existed — the running cluster loop is not orphaned on
36 deploy. Every other loop carries its name as a segment.
37 """
38 return () if loop is Loop.DEPENDENCY_PATCH else (loop.value,)
39 
40 
41def branch_name(
42 candidate: Candidate, loop: Loop = Loop.DEPENDENCY_PATCH
43) -> BranchName:
44 """The deterministic head branch for a bump (also the PR dedup key).
45 
46 Namespaced by loop (``froot/<loop>/…``) so two loops never push the same
47 branch even when they target the same package and version.
48 """
49 return BranchName(
50 value=f"froot/{loop.value}/{_slug(candidate.package)}-{candidate.target}"
51 )
52 
⋯ 15 lines hidden (lines 53–67)
53 
54def branch_package_prefix(
55 package: str, loop: Loop = Loop.DEPENDENCY_PATCH
56) -> str:
57 """The branch prefix shared by all of this loop's bumps of ``package``.
58 
59 ``branch_name`` appends ``-<target>`` to this, so an open PR belongs to this
60 loop's ``package`` iff its branch starts with this prefix *and* the rest
61 parses as a version (reconcile relies on that version-parse to tell apart
62 packages whose slugs prefix one another — ``foo`` vs ``foo-bar``). The loop
63 in the prefix scopes reconcile to its own PRs.
64 """
65 return f"froot/{loop.value}/{_slug(package)}-"
66 
67 
68def bump_workflow_id(
69 repo: TargetRepo, candidate: Candidate, loop: Loop = Loop.DEPENDENCY_PATCH
70) -> str:
71 """The deterministic per-bump workflow id (the dispatch dedup key)."""
72 return "-".join(
73 (
74 "froot-bump",
75 *_loop_id_segment(loop),
76 _slug(repo.repo.owner),
77 _slug(repo.repo.name),
78 _slug(candidate.package),
79 _slug(str(candidate.target)),
80 )
81 )
82 
83 
84def scan_workflow_id(
⋯ 37 lines hidden (lines 85–121)
85 repo: TargetRepo, loop: Loop = Loop.DEPENDENCY_PATCH
86) -> str:
87 """The deterministic per-repo scan-loop workflow id (a singleton)."""
88 return "-".join(
89 (
90 "froot-scan",
91 *_loop_id_segment(loop),
92 _slug(repo.repo.owner),
93 _slug(repo.repo.name),
94 )
95 )
96 
97 
98def review_workflow_id(repo: TargetRepo) -> str:
99 """The deterministic per-repo determinism-review loop id (a singleton)."""
100 return "-".join(
101 ("froot-review", _slug(repo.repo.owner), _slug(repo.repo.name))
102 )
103 
104 
105def pr_review_workflow_id(
106 repo: TargetRepo, pr_number: int, head_sha: str
107) -> str:
108 """The deterministic per-(PR, head SHA) review id (the dispatch dedup key).
109 
110 Keyed on the head SHA so a new commit triggers a fresh review, and
111 re-dispatch of the same commit is a no-op (REJECT_DUPLICATE).
112 """
113 return "-".join(
114 (
115 "froot-pr-review",
116 _slug(repo.repo.owner),
117 _slug(repo.repo.name),
118 _slug(str(pr_number)),
119 _slug(head_sha[:12]),
120 )
121 )
src/froot/workflow/bump_workflow.py · 173 lines
src/froot/workflow/bump_workflow.py173 lines · Python
⋯ 97 lines hidden (lines 1–97)
1"""The per-bump lifecycle workflow — a thin driver over the pure core.
2 
3One durable workflow per (repo, package, target), keyed by the deterministic
4:func:`~froot.policy.naming.bump_workflow_id`, so a bump is proposed at most
5once. It loops: take the pure state machine's next effect, run it as an activity
6(or, for the CI wait, a durable poll-and-sleep), feed the resulting event back
7to :func:`~froot.policy.state_machine.advance`, and repeat until ``Recorded``.
8All nondeterminism is in the activities; the workflow uses only pure state and
9Temporal's own time APIs, so replay is deterministic.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING, assert_never
15 
16from temporalio import workflow
17from temporalio.exceptions import ApplicationError
18 
19with workflow.unsafe.imports_passed_through():
20 from froot.domain.ci import CIStatus, CITimedOut, is_terminal
21 from froot.domain.effects import (
22 AwaitCi,
23 ClosePullRequest,
24 Effect,
25 JudgeChangelog,
26 OpenPullRequest,
27 RecordOutcome,
28 )
29 from froot.domain.events import (
30 ChangelogJudged,
31 CiResolved,
32 LoopEvent,
33 OutcomeRecorded,
34 PullRequestClosed,
35 PullRequestReady,
36 )
37 from froot.domain.outcome import LoopOutcome
38 from froot.domain.state import Recorded
39 from froot.policy.state_machine import TransitionKind, advance, start
40 from froot.workflow import activities
41 from froot.workflow.constants import (
42 ACTIVITY_TIMEOUT,
43 CI_CHECK_TIMEOUT,
44 CI_POLL_INTERVAL,
45 CI_WAIT_DEADLINE,
46 TOOL_RETRY,
47 )
48 from froot.workflow.types import (
49 BumpParams,
50 CiCheckInput,
51 CloseInput,
52 JudgeInput,
53 OpenPrInput,
54 RecordInput,
55 )
56 
57if TYPE_CHECKING:
58 # Used only in the (non-workflow-decorated) helper signatures, so these are
59 # type-only — unlike the run() signature, Temporal does not evaluate them.
60 from froot.domain.loop import Loop
61 from froot.domain.pull_request import PullRequestRef
62 from froot.domain.repo import TargetRepo
63 
64 
65@workflow.defn
66class BumpWorkflow:
67 """The durable loop for a single dependency patch bump."""
68 
69 @workflow.run
70 async def run(self, params: BumpParams) -> LoopOutcome:
71 """Drive the pure state machine to a recorded outcome."""
72 transition = start(params.candidate)
73 while transition.effects:
74 state = transition.next
75 if len(transition.effects) != 1:
76 raise ApplicationError(
77 f"non-linear transition ({len(transition.effects)} "
78 "effects)",
79 non_retryable=True,
80 )
81 event = await self._execute(
82 params.target, params.loop, transition.effects[0]
83 )
84 transition = advance(state, event, close_on_red=params.close_on_red)
85 if transition.kind is TransitionKind.REJECTED:
86 raise ApplicationError(
87 f"rejected transition: {transition.reason}",
88 non_retryable=True,
89 )
90 final = transition.next
91 if not isinstance(final, Recorded):
92 raise ApplicationError(
93 f"loop ended in non-terminal state: {final.kind}",
94 non_retryable=True,
95 )
96 return final.outcome
97 
98 async def _execute(
99 self, target: TargetRepo, loop: Loop, effect: Effect
100 ) -> LoopEvent:
101 """Interpret one effect into an activity (or a durable CI wait).
102 
103 ``loop`` rides in from the workflow's params and is handed to the impure
104 activities (the branch namespace, the labels, the judge's prompt) — so
105 the pure state machine never learns which loop it is running.
106 """
107 match effect:
108 case JudgeChangelog():
⋯ 65 lines hidden (lines 109–173)
109 verdict = await workflow.execute_activity(
110 activities.judge_changelog,
111 JudgeInput(candidate=effect.candidate, loop=loop),
112 start_to_close_timeout=ACTIVITY_TIMEOUT,
113 retry_policy=TOOL_RETRY,
114 )
115 return ChangelogJudged(verdict=verdict)
116 case OpenPullRequest():
117 pr = await workflow.execute_activity(
118 activities.open_pull_request,
119 OpenPrInput(
120 target=target,
121 candidate=effect.candidate,
122 verdict=effect.verdict,
123 loop=loop,
124 ),
125 start_to_close_timeout=ACTIVITY_TIMEOUT,
126 retry_policy=TOOL_RETRY,
127 )
128 return PullRequestReady(pr=pr)
129 case AwaitCi():
130 status = await self._await_ci(target, effect.pr)
131 return CiResolved(status=status)
132 case ClosePullRequest():
133 await workflow.execute_activity(
134 activities.close_pull_request,
135 CloseInput(
136 target=target,
137 pr=effect.pr,
138 failing=effect.failing,
139 loop=loop,
140 ),
141 start_to_close_timeout=CI_CHECK_TIMEOUT,
142 retry_policy=TOOL_RETRY,
143 )
144 return PullRequestClosed()
145 case RecordOutcome():
146 await workflow.execute_activity(
147 activities.record_outcome,
148 RecordInput(
149 target=target, outcome=effect.outcome, loop=loop
150 ),
151 start_to_close_timeout=CI_CHECK_TIMEOUT,
152 retry_policy=TOOL_RETRY,
153 )
154 return OutcomeRecorded()
155 assert_never(effect)
156 
157 async def _await_ci(
158 self, target: TargetRepo, pr: PullRequestRef
159 ) -> CIStatus:
160 """Durably poll the repo's CI until it resolves or the deadline."""
161 deadline = workflow.now() + CI_WAIT_DEADLINE
162 while True:
163 status = await workflow.execute_activity(
164 activities.check_ci,
165 CiCheckInput(target=target, head_sha=pr.head_sha),
166 start_to_close_timeout=CI_CHECK_TIMEOUT,
167 retry_policy=TOOL_RETRY,
168 )
169 if is_terminal(status):
170 return status
171 if workflow.now() >= deadline:
172 return CITimedOut()
173 await workflow.sleep(CI_POLL_INTERVAL)
The unit of work2🎬 Relax the invariant, move it to the policy

One candidate type, two loops' reach

src/froot/domain/candidate.py

dependency-patch only ever proposes a patch; security-patch must reach a minor or major to clear a CVE. So the candidate type can no longer enforce "patch-only" — that decision moves into the per-loop selection policy, and the type keeps only what is true for any loop: a stable target newer than current.

src/froot/domain/candidate.py · 105 lines
src/froot/domain/candidate.py105 lines · Python
⋯ 23 lines hidden (lines 1–23)
1"""A candidate: a single dependency bump a loop wants to propose.
2 
3The loop's bounded unit of work, shared by every loop. The type enforces only
4what is true for *any* loop — the target is a stable release strictly newer than
5the installed version, so a candidate can never go backward or step onto a
6prerelease. *How much* of a bump is allowed (patch-only for dependency-patch, or
7whatever clears an advisory for security-patch) is the selecting policy's call,
8not the type's — see :mod:`froot.policy.candidates`. The optional
9``justification`` carries a loop's "why" (e.g. the advisories a security bump
10clears) to the PR body and the judge, without any other loop having to care.
11"""
12 
13from __future__ import annotations
14 
15from typing import Self
16 
17from pydantic import Field, model_validator
18 
19from froot.domain.base import Frozen
20from froot.domain.ecosystem import Ecosystem
21from froot.domain.version import Version
22 
23 
24class Candidate(Frozen):
25 """A proposed upgrade of a single dependency.
26 
27 Attributes:
28 package: The dependency's name (e.g. ``"left-pad"`` or a scoped
29 ``"@scope/pkg"``).
30 ecosystem: The package manager the dependency belongs to.
31 current: The installed version.
32 target: The proposed version — a stable release strictly newer than
33 ``current`` (the loop's policy decides how far it may reach).
34 justification: An optional short "why" for loops that need one (e.g.
35 ``"clears GHSA-… (CVE-…)"``); ``None`` when the bump speaks for
36 itself, as a patch does.
37 """
38 
39 package: str = Field(min_length=1)
40 ecosystem: Ecosystem
41 current: Version
42 target: Version
43 justification: str | None = None
44 
45 @model_validator(mode="after")
46 def _require_forward_stable(self) -> Self:
47 """Reject a target that is not a stable release newer than current."""
48 if not self.target.is_stable:
49 raise ValueError(
50 f"{self.target} is a prerelease; not a candidate target "
51 f"for {self.package!r}"
52 )
53 if not self.target > self.current:
54 raise ValueError(
55 f"{self.target} is not newer than {self.current} "
56 f"for {self.package!r}"
57 )
58 return self
59 
60 def __str__(self) -> str:
⋯ 45 lines hidden (lines 61–105)
61 """Render as ``package current -> target``."""
62 return f"{self.package} {self.current} -> {self.target}"
63 
64 
65class InstalledPackage(Frozen):
66 """A direct dependency and the version currently locked for it.
67 
68 The raw material the security-patch signal works from: froot can only bump a
69 *direct* dependency (a transitive vuln needs its parent moved), so this is
70 the set the package-manager adapter reads from the lockfile, and what OSV is
71 asked about. Versions that don't parse as a :class:`Version` are dropped by
72 the adapter before they get here (conservative, same as the patch loop).
73 
74 Attributes:
75 package: The dependency's name.
76 ecosystem: The package manager it belongs to.
77 version: The installed (locked) version.
78 """
79 
80 package: str = Field(min_length=1)
81 ecosystem: Ecosystem
82 version: Version
83 
84 
85class AvailableUpgrade(Frozen):
86 """An installed dependency and the published versions it could move to.
87 
88 The raw material the package-manager adapter reports (e.g. from ``npm
89 outdated`` + the published version list). It is deliberately *not* yet a
90 :class:`Candidate`: choosing which available version is the right
91 patch-level target is business logic, and it lives in the pure
92 :func:`froot.policy.candidates.select_patch_candidates`, not in the adapter.
93 
94 Attributes:
95 package: The dependency's name.
96 ecosystem: The package manager it belongs to.
97 current: The installed version.
98 available: The published versions that could be upgraded to (any
99 order; the policy selects among them).
100 """
101 
102 package: str = Field(min_length=1)
103 ecosystem: Ecosystem
104 current: Version
105 available: tuple[Version, ...]
The security signal3🎬 Q&A from the source

The installed set, and one source for every ecosystem

src/froot/adapters/npm.pysrc/froot/adapters/osv.py

Q1. What does security-patch read?

Not "what's outdated" — what's installed. A new list_installed reads the direct dependencies and their locked versions from the lockfile (no install). Only direct deps: froot can bump those; a transitive vuln needs its parent moved, which is not a bump froot makes.

src/froot/adapters/npm.py · 193 lines
src/froot/adapters/npm.py193 lines · Python
⋯ 148 lines hidden (lines 1–148)
1"""The npm package-manager adapter.
2 
3Reads upgrade availability from the committed manifest + lockfile (no install
4needed) plus ``npm view <pkg> versions``, and regenerates the manifest +
5lockfile with ``npm install <pkg>@<target> --package-lock-only
6--ignore-scripts`` — lockfile-only, no install scripts, so no third-party
7dependency code ever runs in the worker (the real install + tests happen in the
8repo's CI, the oracle).
9 
10The installed baseline comes from ``package-lock.json``, *not* ``npm
11outdated``: ``npm outdated``'s ``current`` field is absent without a
12``node_modules`` tree, and froot only does a clone (never an install). The
13parsing is split into pure module functions so it is unit-tested with fixtures,
14away from the subprocess and the network.
15"""
16 
17from __future__ import annotations
18 
19import json
20from typing import TYPE_CHECKING
21 
22from froot.adapters._proc import run_text
23from froot.domain.candidate import AvailableUpgrade, InstalledPackage
24from froot.domain.version import Version
25from froot.result import Ok
26 
27if TYPE_CHECKING:
28 from pathlib import Path
29 
30 from froot.domain.candidate import Candidate
31 from froot.domain.repo import TargetRepo
32 
33_NODE_MODULES = "node_modules/"
34 
35 
36def parse_direct_dependencies(package_json: str) -> frozenset[str]:
37 """The direct dependency names from a ``package.json`` (deps + devDeps).
38 
39 Only direct dependencies are bumped: ``npm install <pkg>`` on a transitive
40 dependency would promote it to a direct one, which is not a patch.
41 """
42 data = json.loads(package_json)
43 names: set[str] = set()
44 if isinstance(data, dict):
45 for field in ("dependencies", "devDependencies"):
46 section = data.get(field)
47 if isinstance(section, dict):
48 names.update(name for name in section if isinstance(name, str))
49 return frozenset(names)
50 
51 
52def parse_locked_versions(package_lock: str) -> dict[str, str]:
53 """Resolved version per top-level dependency from a ``package-lock.json``.
54 
55 Reads the lockfileVersion 2/3 ``packages`` map (keys
56 ``node_modules/<name>``, skipping nested ``.../node_modules/...`` transitive
57 entries), falling back to the legacy v1 top-level ``dependencies`` map.
58 """
59 data = json.loads(package_lock)
60 if not isinstance(data, dict):
61 return {}
62 versions: dict[str, str] = {}
63 packages = data.get("packages")
64 if isinstance(packages, dict):
65 for key, info in packages.items():
66 if not (isinstance(key, str) and key.startswith(_NODE_MODULES)):
67 continue
68 name = key[len(_NODE_MODULES) :]
69 if "/node_modules/" in name: # a nested (transitive) entry
70 continue
71 version = info.get("version") if isinstance(info, dict) else None
72 if isinstance(version, str):
73 versions[name] = version
74 if versions:
75 return versions
76 legacy = data.get("dependencies") # lockfileVersion 1
77 if isinstance(legacy, dict):
78 for name, info in legacy.items():
79 version = info.get("version") if isinstance(info, dict) else None
80 if isinstance(name, str) and isinstance(version, str):
81 versions[name] = version
82 return versions
83 
84 
85def parse_versions(stdout: str) -> tuple[Version, ...]:
86 """Parse ``npm view <pkg> versions --json`` into domain versions.
87 
88 Accepts a JSON array (the usual case) or a bare JSON string (a single
89 version); empty or non-JSON output (e.g. a failed lookup) yields ``()``.
90 Unparseable entries are dropped.
91 """
92 if not stdout.strip():
93 return ()
94 try:
95 raw = json.loads(stdout)
96 except json.JSONDecodeError:
97 return ()
98 items = raw if isinstance(raw, list) else [raw]
99 versions: list[Version] = []
100 for item in items:
101 if isinstance(item, str):
102 match Version.parse(item):
103 case Ok(version):
104 versions.append(version)
105 case _:
106 continue
107 return tuple(versions)
108 
109 
110class NpmPackageManager:
111 """A :class:`~froot.ports.protocols.PackageManager` backed by ``npm``."""
112 
113 async def list_upgrades(
114 self, target: TargetRepo, workspace: Path
115 ) -> tuple[AvailableUpgrade, ...]:
116 """Report each direct dependency and the versions available to it."""
117 direct = parse_direct_dependencies(
118 (workspace / "package.json").read_text()
119 )
120 lock_path = workspace / "package-lock.json"
121 locked = (
122 parse_locked_versions(lock_path.read_text())
123 if lock_path.exists()
124 else {}
125 )
126 upgrades: list[AvailableUpgrade] = []
127 for name in sorted(direct):
128 current_text = locked.get(name)
129 if current_text is None:
130 continue
131 match Version.parse(current_text):
132 case Ok(current):
133 pass
134 case _:
135 continue
136 _, versions_out, _ = await run_text(
137 "npm", "view", name, "versions", "--json", cwd=workspace
138 )
139 upgrades.append(
140 AvailableUpgrade(
141 package=name,
142 ecosystem=target.ecosystem,
143 current=current,
144 available=parse_versions(versions_out),
145 )
146 )
147 return tuple(upgrades)
148 
149 async def list_installed(
150 self, target: TargetRepo, workspace: Path
151 ) -> tuple[InstalledPackage, ...]:
152 """Report each direct dependency and its locked version (no network)."""
153 direct = parse_direct_dependencies(
154 (workspace / "package.json").read_text()
155 )
156 lock_path = workspace / "package-lock.json"
157 locked = (
158 parse_locked_versions(lock_path.read_text())
159 if lock_path.exists()
160 else {}
161 )
162 installed: list[InstalledPackage] = []
163 for name in sorted(direct):
164 current_text = locked.get(name)
165 if current_text is None:
166 continue
167 match Version.parse(current_text):
168 case Ok(version):
169 installed.append(
170 InstalledPackage(
171 package=name,
172 ecosystem=target.ecosystem,
173 version=version,
174 )
175 )
176 case _:
177 continue
178 return tuple(installed)
⋯ 15 lines hidden (lines 179–193)
179 
180 async def apply_patch_bump(
181 self, candidate: Candidate, workspace: Path
182 ) -> None:
183 """Rewrite the manifest + lockfile to the target (lockfile-only)."""
184 code, out, err = await run_text(
185 "npm",
186 "install",
187 f"{candidate.package}@{candidate.target}",
188 "--package-lock-only",
189 "--ignore-scripts",
190 cwd=workspace,
191 )
192 if code != 0:
193 raise RuntimeError(f"npm install failed ({code}): {err or out}")

Q2. Where do advisories come from?

OSV.dev — Google's cross-ecosystem vulnerability database, which speaks both npm and PyPI. So a single adapter covers every ecosystem froot patches. Two calls: a batch query of the installed set returns the vuln ids per package, then each vuln record is fetched for its affected ranges. Best-effort by contract.

src/froot/adapters/osv.py · 162 lines
src/froot/adapters/osv.py162 lines · Python
⋯ 94 lines hidden (lines 1–94)
1"""The OSV.dev advisory source — froot's security signal, one source for all.
2 
3Backs :class:`~froot.ports.protocols.AdvisorySource`. OSV (osv.dev) is Google's
4cross-ecosystem vuln database; it speaks both ``npm`` and ``PyPI``, so one
5single adapter covers every ecosystem froot patches. Two calls: a batch query of
6the installed ``(name, ecosystem, version)`` set returns the vuln ids affecting
7each, then each vuln record is fetched for its affected ranges (the fixed
8versions the policy needs). No auth, no key.
9 
10Best-effort by contract: a failed batch yields no advisories (the loop simply
11finds nothing to do this tick) and a failed single fetch drops that one vuln, so
12a flaky OSV never blocks the loop. The adapter only *shapes* OSV's JSON into
13domain values; deciding the clearing target is the pure
14:func:`froot.policy.candidates.select_security_candidates`.
15"""
16 
17from __future__ import annotations
18 
19from typing import TYPE_CHECKING, Any, assert_never, final
20 
21import httpx
22 
23from froot.domain.advisory import Advisory, VulnRange
24from froot.domain.ecosystem import Ecosystem
25 
26if TYPE_CHECKING:
27 from froot.domain.candidate import InstalledPackage
28 
29_API = "https://api.osv.dev"
30_TIMEOUT = 30.0
31 
32 
33def _osv_ecosystem(ecosystem: Ecosystem) -> str:
34 """OSV's name for an ecosystem (case-sensitive: ``npm`` / ``PyPI``)."""
35 match ecosystem:
36 case Ecosystem.NPM:
37 return "npm"
38 case Ecosystem.UV:
39 return "PyPI"
40 assert_never(ecosystem)
41 
42 
43def _ranges_from_affected(affected: Any) -> tuple[VulnRange, ...]:
44 """Flatten an affected entry's ranges' events into ``VulnRange``s.
45 
46 OSV events are a sequence of ``{introduced: X}`` / ``{fixed: Y}`` markers;
47 each ``introduced`` opens a span that the next ``fixed`` (if any) closes.
48 """
49 ranges: list[VulnRange] = []
50 for spec in affected.get("ranges", []):
51 introduced: str | None = None
52 for event in spec.get("events", []):
53 if "introduced" in event:
54 if introduced is not None:
55 ranges.append(VulnRange(introduced=introduced))
56 introduced = str(event["introduced"])
57 elif "fixed" in event and introduced is not None:
58 ranges.append(
59 VulnRange(introduced=introduced, fixed=str(event["fixed"]))
60 )
61 introduced = None
62 if introduced is not None:
63 ranges.append(VulnRange(introduced=introduced))
64 return tuple(ranges)
65 
66 
67def _advisory_from_record(
68 record: Any, package: InstalledPackage, osv_ecosystem: str
69) -> Advisory | None:
70 """Shape one OSV vuln record into an :class:`Advisory` for ``package``.
71 
72 Keeps only the entries for this package + ecosystem (a vuln can name
73 several), so the policy never matches a range from a different package.
74 """
75 ranges: list[VulnRange] = []
76 for affected in record.get("affected", []):
77 pkg = affected.get("package", {})
78 if (
79 pkg.get("name") == package.package
80 and pkg.get("ecosystem") == osv_ecosystem
81 ):
82 ranges.extend(_ranges_from_affected(affected))
83 if not ranges:
84 return None
85 return Advisory(
86 id=str(record["id"]),
87 aliases=tuple(str(a) for a in record.get("aliases", [])),
88 package=package.package,
89 ecosystem=package.ecosystem,
90 ranges=tuple(ranges),
91 )
92 
93 
94@final
95class OsvAdvisorySource:
96 """An :class:`~froot.ports.protocols.AdvisorySource` over OSV.dev."""
97 
98 async def advisories(
99 self, installed: tuple[InstalledPackage, ...]
100 ) -> tuple[Advisory, ...]:
101 """Return the advisories affecting ``installed`` (best-effort)."""
102 if not installed:
103 return ()
104 async with httpx.AsyncClient(base_url=_API, timeout=_TIMEOUT) as client:
105 vuln_ids = await self._query_batch(client, installed)
106 advisories: list[Advisory] = []
107 for package, ids in vuln_ids:
108 osv_ecosystem = _osv_ecosystem(package.ecosystem)
109 for vuln_id in ids:
110 record = await self._fetch_vuln(client, vuln_id)
111 if record is None:
112 continue
113 advisory = _advisory_from_record(
114 record, package, osv_ecosystem
115 )
116 if advisory is not None:
117 advisories.append(advisory)
118 return tuple(advisories)
119 
120 async def _query_batch(
121 self,
122 client: httpx.AsyncClient,
123 installed: tuple[InstalledPackage, ...],
124 ) -> list[tuple[InstalledPackage, tuple[str, ...]]]:
125 """Batch-query OSV; return each package paired with its vuln ids."""
126 queries = [
127 {
128 "package": {
129 "name": package.package,
130 "ecosystem": _osv_ecosystem(package.ecosystem),
131 },
132 "version": str(package.version),
133 }
134 for package in installed
135 ]
⋯ 27 lines hidden (lines 136–162)
136 try:
137 resp = await client.post(
138 "/v1/querybatch", json={"queries": queries}
139 )
140 resp.raise_for_status()
141 except httpx.HTTPError:
142 return [] # best-effort: a failed batch means nothing to do
143 results = resp.json().get("results", [])
144 out: list[tuple[InstalledPackage, tuple[str, ...]]] = []
145 for package, result in zip(installed, results, strict=False):
146 ids = tuple(
147 str(v["id"]) for v in result.get("vulns", []) if "id" in v
148 )
149 if ids:
150 out.append((package, ids))
151 return out
152 
153 async def _fetch_vuln(
154 self, client: httpx.AsyncClient, vuln_id: str
155 ) -> Any | None:
156 """Fetch one vuln's full record, or ``None`` if it can't be read."""
157 try:
158 resp = await client.get(f"/v1/vulns/{vuln_id}")
159 resp.raise_for_status()
160 except httpx.HTTPError:
161 return None
162 return resp.json()
The security signal4🎬 The decision, pure and tested

The lowest version that clears them all

src/froot/policy/candidates.py

This is the one piece of genuinely new logic, and it is pure — fed raw advisory facts, tested without the network. For one advisory it finds the affected range holding the installed version and returns its fix; an unfixed or unparseable range yields nothing (froot won't propose a bump it can't reason about).

src/froot/policy/candidates.py · 163 lines
src/froot/policy/candidates.py163 lines · Python
⋯ 74 lines hidden (lines 1–74)
1"""Candidate selection: each loop's notion of "the right target", pure.
2 
3Given the raw facts an adapter gathered, decide the version to propose and build
4a :class:`Candidate`. dependency-patch picks the *highest stable patch* of the
5installed version; security-patch picks the *lowest version that clears every
6advisory* affecting it (often a minor or major bump). The adapters gather facts
7(:class:`AvailableUpgrade`, :class:`Advisory`); the decisions live here, where
8they are tested without the network, and both end sorted by package.
9"""
10 
11from __future__ import annotations
12 
13from collections import defaultdict
14from typing import TYPE_CHECKING
15 
16from froot.domain.candidate import AvailableUpgrade, Candidate
17from froot.domain.version import Version
18from froot.result import Ok
19 
20if TYPE_CHECKING:
21 from collections.abc import Iterable
22 
23 from froot.domain.advisory import Advisory
24 from froot.domain.candidate import InstalledPackage
25 
26 
27def _best_patch_target(upgrade: AvailableUpgrade) -> Version | None:
28 """The highest available stable patch of the installed version, if any."""
29 patches = [
30 version
31 for version in upgrade.available
32 if version.is_patch_bump_of(upgrade.current)
33 ]
34 return max(patches) if patches else None
35 
36 
37def select_patch_candidates(
38 upgrades: Iterable[AvailableUpgrade],
39) -> tuple[Candidate, ...]:
40 """Reduce available upgrades to the patch candidates worth proposing.
41 
42 Args:
43 upgrades: The raw availability facts, one per outdated dependency.
44 
45 Returns:
46 One :class:`Candidate` per dependency that has a patch-level
47 upgrade (targeting the highest available patch), sorted by package.
48 Dependencies whose only upgrades cross the minor/major line — or step
49 onto a prerelease — are dropped.
50 """
51 candidates = [
52 Candidate(
53 package=upgrade.package,
54 ecosystem=upgrade.ecosystem,
55 current=upgrade.current,
56 target=target,
57 )
58 for upgrade in upgrades
59 if (target := _best_patch_target(upgrade)) is not None
60 ]
61 return tuple(sorted(candidates, key=lambda candidate: candidate.package))
62 
63 
64def _introduced_reached(version: Version, introduced: str) -> bool:
65 """Whether ``version`` is at or past an advisory range's lower bound."""
66 if introduced == "0": # OSV's "from the start"
67 return True
68 match Version.parse(introduced):
69 case Ok(bound):
70 return version >= bound
71 case _:
72 return False # unparseable bound — conservatively not in range
73 
74 
75def _clearing_version(version: Version, advisory: Advisory) -> Version | None:
76 """The lowest version that clears ``advisory`` for ``version``, or ``None``.
77 
78 Finds the advisory's affected range that holds ``version`` and returns its
79 fixed version. ``None`` when the holding range has no published fix, or its
80 fix doesn't parse as a stable semver (conservative: froot won't propose a
81 bump it can't reason about).
82 """
83 for span in advisory.ranges:
84 if not _introduced_reached(version, span.introduced):
85 continue
86 if span.fixed is None:
87 continue
88 match Version.parse(span.fixed):
89 case Ok(fixed) if version < fixed:
90 return fixed
91 case _:
92 continue
93 return None
⋯ 70 lines hidden (lines 94–163)
94 
95 
96def _justification(cleared: list[Advisory], others_remain: bool) -> str:
97 """The PR-body "why": the advisories this bump clears, honestly.
98 
99 Names only the advisories the target actually clears, never implying it
100 clears more. If the package has *other* advisories with no usable fix, it
101 says so plainly rather than letting the reviewer assume the bump is a full
102 fix — froot only bumps direct deps, so the rest are out of its reach.
103 """
104 names = [", ".join((a.id, *a.aliases)) for a in cleared]
105 body = "Clears " + "; ".join(names) + "."
106 if others_remain:
107 body += (
108 " Note: this package has further advisories with no usable fix; "
109 "this bump does not clear those."
110 )
111 return body
112 
113 
114def select_security_candidates(
115 installed: tuple[InstalledPackage, ...],
116 advisories: tuple[Advisory, ...],
117) -> tuple[Candidate, ...]:
118 """Reduce installed packages + advisories to the security bumps to propose.
119 
120 Args:
121 installed: The direct dependencies and their locked versions.
122 advisories: The advisories affecting them (one per vulnerability).
123 
124 Returns:
125 One :class:`Candidate` per package with at least one clearable advisory,
126 targeting the *lowest version that clears those* (the max of the
127 per-advisory fixes), justified by the advisory ids. When a package also
128 has advisories with no usable fix, the bump is still proposed (it cuts
129 the surface) and the justification says the rest are not cleared. A
130 package with no clearable advisory at all is dropped. Sorted by package.
131 """
132 by_package: dict[tuple[str, object], list[Advisory]] = defaultdict(list)
133 for advisory in advisories:
134 by_package[(advisory.package, advisory.ecosystem)].append(advisory)
135 
136 candidates: list[Candidate] = []
137 for package in installed:
138 package_advisories = by_package.get(
139 (package.package, package.ecosystem), []
140 )
141 cleared: list[Advisory] = []
142 target: Version | None = None
143 for advisory in package_advisories:
144 fix = _clearing_version(package.version, advisory)
145 if fix is None:
146 continue
147 cleared.append(advisory)
148 target = fix if target is None else max(target, fix)
149 if target is None or not target > package.version:
150 continue
151 candidates.append(
152 Candidate(
153 package=package.package,
154 ecosystem=package.ecosystem,
155 current=package.version,
156 target=target,
157 justification=_justification(
158 cleared,
159 others_remain=len(cleared) < len(package_advisories),
160 ),
161 )
162 )
163 return tuple(sorted(candidates, key=lambda candidate: candidate.package))

A package can have several advisories. The target is the max of the per-advisory fixes — the lowest version that clears all of them at once.

src/froot/policy/candidates.py · 163 lines
src/froot/policy/candidates.py163 lines · Python
⋯ 113 lines hidden (lines 1–113)
1"""Candidate selection: each loop's notion of "the right target", pure.
2 
3Given the raw facts an adapter gathered, decide the version to propose and build
4a :class:`Candidate`. dependency-patch picks the *highest stable patch* of the
5installed version; security-patch picks the *lowest version that clears every
6advisory* affecting it (often a minor or major bump). The adapters gather facts
7(:class:`AvailableUpgrade`, :class:`Advisory`); the decisions live here, where
8they are tested without the network, and both end sorted by package.
9"""
10 
11from __future__ import annotations
12 
13from collections import defaultdict
14from typing import TYPE_CHECKING
15 
16from froot.domain.candidate import AvailableUpgrade, Candidate
17from froot.domain.version import Version
18from froot.result import Ok
19 
20if TYPE_CHECKING:
21 from collections.abc import Iterable
22 
23 from froot.domain.advisory import Advisory
24 from froot.domain.candidate import InstalledPackage
25 
26 
27def _best_patch_target(upgrade: AvailableUpgrade) -> Version | None:
28 """The highest available stable patch of the installed version, if any."""
29 patches = [
30 version
31 for version in upgrade.available
32 if version.is_patch_bump_of(upgrade.current)
33 ]
34 return max(patches) if patches else None
35 
36 
37def select_patch_candidates(
38 upgrades: Iterable[AvailableUpgrade],
39) -> tuple[Candidate, ...]:
40 """Reduce available upgrades to the patch candidates worth proposing.
41 
42 Args:
43 upgrades: The raw availability facts, one per outdated dependency.
44 
45 Returns:
46 One :class:`Candidate` per dependency that has a patch-level
47 upgrade (targeting the highest available patch), sorted by package.
48 Dependencies whose only upgrades cross the minor/major line — or step
49 onto a prerelease — are dropped.
50 """
51 candidates = [
52 Candidate(
53 package=upgrade.package,
54 ecosystem=upgrade.ecosystem,
55 current=upgrade.current,
56 target=target,
57 )
58 for upgrade in upgrades
59 if (target := _best_patch_target(upgrade)) is not None
60 ]
61 return tuple(sorted(candidates, key=lambda candidate: candidate.package))
62 
63 
64def _introduced_reached(version: Version, introduced: str) -> bool:
65 """Whether ``version`` is at or past an advisory range's lower bound."""
66 if introduced == "0": # OSV's "from the start"
67 return True
68 match Version.parse(introduced):
69 case Ok(bound):
70 return version >= bound
71 case _:
72 return False # unparseable bound — conservatively not in range
73 
74 
75def _clearing_version(version: Version, advisory: Advisory) -> Version | None:
76 """The lowest version that clears ``advisory`` for ``version``, or ``None``.
77 
78 Finds the advisory's affected range that holds ``version`` and returns its
79 fixed version. ``None`` when the holding range has no published fix, or its
80 fix doesn't parse as a stable semver (conservative: froot won't propose a
81 bump it can't reason about).
82 """
83 for span in advisory.ranges:
84 if not _introduced_reached(version, span.introduced):
85 continue
86 if span.fixed is None:
87 continue
88 match Version.parse(span.fixed):
89 case Ok(fixed) if version < fixed:
90 return fixed
91 case _:
92 continue
93 return None
94 
95 
96def _justification(cleared: list[Advisory], others_remain: bool) -> str:
97 """The PR-body "why": the advisories this bump clears, honestly.
98 
99 Names only the advisories the target actually clears, never implying it
100 clears more. If the package has *other* advisories with no usable fix, it
101 says so plainly rather than letting the reviewer assume the bump is a full
102 fix — froot only bumps direct deps, so the rest are out of its reach.
103 """
104 names = [", ".join((a.id, *a.aliases)) for a in cleared]
105 body = "Clears " + "; ".join(names) + "."
106 if others_remain:
107 body += (
108 " Note: this package has further advisories with no usable fix; "
109 "this bump does not clear those."
110 )
111 return body
112 
113 
114def select_security_candidates(
115 installed: tuple[InstalledPackage, ...],
116 advisories: tuple[Advisory, ...],
117) -> tuple[Candidate, ...]:
118 """Reduce installed packages + advisories to the security bumps to propose.
119 
120 Args:
121 installed: The direct dependencies and their locked versions.
122 advisories: The advisories affecting them (one per vulnerability).
123 
124 Returns:
125 One :class:`Candidate` per package with at least one clearable advisory,
126 targeting the *lowest version that clears those* (the max of the
127 per-advisory fixes), justified by the advisory ids. When a package also
128 has advisories with no usable fix, the bump is still proposed (it cuts
129 the surface) and the justification says the rest are not cleared. A
130 package with no clearable advisory at all is dropped. Sorted by package.
131 """
132 by_package: dict[tuple[str, object], list[Advisory]] = defaultdict(list)
133 for advisory in advisories:
134 by_package[(advisory.package, advisory.ecosystem)].append(advisory)
135 
136 candidates: list[Candidate] = []
137 for package in installed:
138 package_advisories = by_package.get(
139 (package.package, package.ecosystem), []
140 )
141 cleared: list[Advisory] = []
142 target: Version | None = None
143 for advisory in package_advisories:
144 fix = _clearing_version(package.version, advisory)
145 if fix is None:
146 continue
147 cleared.append(advisory)
148 target = fix if target is None else max(target, fix)
149 if target is None or not target > package.version:
150 continue
151 candidates.append(
152 Candidate(
153 package=package.package,
154 ecosystem=package.ecosystem,
155 current=package.version,
156 target=target,
157 justification=_justification(
158 cleared,
159 others_remain=len(cleared) < len(package_advisories),
160 ),
161 )
162 )
163 return tuple(sorted(candidates, key=lambda candidate: candidate.package))
Wiring it in5🎬 The per-loop seam, and a scoping fix

One activity, two signals; reconcile per loop

src/froot/workflow/activities.pysrc/froot/policy/reconcile.py

The scan activity is where the two signals diverge — and the only place they do. A match on the loop picks dependency-patch's "available upgrades → highest patch" or security-patch's "installed set → OSV → clearing target". Both lazy- import their stack per arm and both feed a pure selection policy.

src/froot/workflow/activities.py · 435 lines
src/froot/workflow/activities.py435 lines · Python
⋯ 63 lines hidden (lines 1–63)
1"""Activities: the effect interpreters that wrap the adapters.
2 
3Each activity is the impure boundary for one effect. Adapter imports are LAZY
4(inside the bodies) so the model and HTTP stacks never enter a workflow sandbox;
5the pure policy and domain imports stay at module level. Activities return
6domain values — the workflow wraps them into events and feeds the pure state
7machine. The activity signatures' types are evaluated at runtime by Temporal's
8data converter, so the domain imports here are deliberately not deferred.
9"""
10 
11from __future__ import annotations
12 
13import json
14import logging
15import tempfile
16from pathlib import Path
17from typing import TYPE_CHECKING, assert_never
18 
19from temporalio import activity
20 
21from froot.domain.candidate import Candidate
22from froot.domain.changelog import ChangelogVerdict, UnknownVerdict
23from froot.domain.ci import CIStatus
24from froot.domain.determinism import AnalysisResult, FrontierVerdict
25from froot.domain.loop import Loop
26from froot.domain.pull_request import PullRequestRef
27from froot.domain.repo import TargetRepo
28from froot.policy.compose import pr_labels, pull_request_draft
29from froot.policy.determinism import analyze_workflow_surface
30from froot.policy.naming import (
31 branch_name,
32 bump_workflow_id,
33 pr_review_workflow_id,
35from froot.policy.review_comment import REVIEW_MARKER, render_review_comment
36from froot.workflow.types import (
37 AdjudicateInput,
38 CiCheckInput,
39 CloseInput,
40 DispatchInput,
41 DispatchReviewInput,
42 JudgeInput,
43 OpenPrInput,
44 PostReviewInput,
45 PrReviewParams,
46 ReconcileInput,
47 RecordInput,
48 ScanCandidatesInput,
50 
51if TYPE_CHECKING:
52 from froot.ports.protocols import PackageManager
53 
54_log = logging.getLogger("froot.outcome")
55_review_log = logging.getLogger("froot.review")
56_reconcile_log = logging.getLogger("froot.reconcile")
57 
58 
59def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
60 """The directory the manifest lives in (a monorepo subdir, or the root)."""
61 return workspace / target.manifest_dir if target.manifest_dir else workspace
62 
63 
64async def _select_candidates(
65 loop: Loop,
66 target: TargetRepo,
67 package_manager: PackageManager,
68 manifest_dir: Path,
69) -> tuple[Candidate, ...]:
70 """Gather this loop's signal from the checkout and select its candidates.
71 
72 The one genuinely per-loop seam: dependency-patch reads the available
73 upgrades and picks the highest patch; security-patch reads the installed,
74 asks OSV for advisories, and picks the lowest version clearing each. The
75 impure sources are lazy-imported per arm so neither drags the other's stack
76 into a sandbox. Both feed a pure selection policy.
77 """
78 match loop:
79 case Loop.DEPENDENCY_PATCH:
80 from froot.policy.candidates import select_patch_candidates
81 
82 upgrades = await package_manager.list_upgrades(target, manifest_dir)
83 return select_patch_candidates(upgrades)
84 case Loop.SECURITY_PATCH:
85 return await _select_security_candidates(
86 target, package_manager, manifest_dir
87 )
88 assert_never(loop)
89 
⋯ 346 lines hidden (lines 90–435)
90 
91async def _select_security_candidates(
92 target: TargetRepo, package_manager: PackageManager, manifest_dir: Path
93) -> tuple[Candidate, ...]:
94 """Security signal: installed set, OSV advisories, clearing targets."""
95 from froot.adapters.osv import OsvAdvisorySource
96 from froot.policy.candidates import select_security_candidates
97 
98 installed = await package_manager.list_installed(target, manifest_dir)
99 advisories = await OsvAdvisorySource().advisories(installed)
100 return select_security_candidates(installed, advisories)
101 
102 
103@activity.defn
104async def scan_candidates(
105 params: ScanCandidatesInput,
106) -> tuple[Candidate, ...]:
107 """Check out the repo and select this loop's candidates."""
108 from froot.adapters.github import GitHubForge
109 from froot.adapters.registry import package_manager_for
110 
111 forge = GitHubForge()
112 package_manager = package_manager_for(params.target.ecosystem)
113 with tempfile.TemporaryDirectory() as tmp:
114 workspace = Path(tmp)
115 await forge.checkout(params.target, workspace)
116 candidates = await _select_candidates(
117 params.loop,
118 params.target,
119 package_manager,
120 _manifest_dir(params.target, workspace),
121 )
122 return candidates
123 
124 
125@activity.defn
126async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
127 """Fetch the candidate's changelog and get the model's typed verdict.
128 
129 The model is froot's one thin, non-load-bearing judgment: a clean verdict
130 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
131 erroring must not stall the spine. A judge failure degrades to
132 ``UnknownVerdict`` — the bump proceeds, the human still gets the PR, and the
133 dashboard records the verdict as unknown — rather than failing (and then
134 retrying) the activity. Only the model call is guarded; the fetch is already
135 best-effort (returns ``None``, not an exception). The loop selects what the
136 model is asked (clean-patch vs breaking-change-on-a-security-bump).
137 """
138 from froot.adapters.changelog_http import HttpChangelogSource
139 from froot.adapters.model_judge import PydanticAiJudge
140 
141 changelog = await HttpChangelogSource().fetch(params.candidate)
142 if changelog is None:
143 return UnknownVerdict(rationale="No changelog could be fetched.")
144 try:
145 return await PydanticAiJudge().judge(changelog, params.loop)
146 except Exception as exc:
147 activity.logger.warning(
148 "changelog judge unavailable for %s; degrading to unknown: %r",
149 params.candidate.package,
150 exc,
151 )
152 return UnknownVerdict(
153 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
154 )
155 
156 
157@activity.defn
158async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
159 """Regenerate manifest+lockfile and open (idempotently) the bump's PR."""
160 from froot.adapters.github import GitHubForge
161 from froot.adapters.registry import package_manager_for
162 
163 forge = GitHubForge()
164 package_manager = package_manager_for(params.target.ecosystem)
165 branch = branch_name(params.candidate, params.loop)
166 existing = await forge.find_open_pull_request(params.target, branch)
167 if existing is not None:
168 return existing
169 draft = pull_request_draft(
170 params.target, params.candidate, params.verdict, params.loop
171 )
172 with tempfile.TemporaryDirectory() as tmp:
173 workspace = Path(tmp)
174 await forge.checkout(params.target, workspace)
175 await package_manager.apply_patch_bump(
176 params.candidate, _manifest_dir(params.target, workspace)
177 )
178 await forge.push_branch(workspace, branch, draft.title)
179 return await forge.open_pull_request(params.target, draft)
180 
181 
182@activity.defn
183async def check_ci(params: CiCheckInput) -> CIStatus:
184 """Read the repo's CI status for the PR's head commit (the oracle)."""
185 from froot.adapters.github import GitHubForge
186 
187 return await GitHubForge().ci_status(params.target, params.head_sha)
188 
189 
190@activity.defn
191async def record_outcome(params: RecordInput) -> None:
192 """Label the PR and log the run telemetry — the signal-update."""
193 from froot.adapters.github import GitHubForge
194 
195 outcome = params.outcome
196 await GitHubForge().add_labels(
197 params.target, outcome.pr.number, pr_labels(params.loop)
198 )
199 _log.info(
200 json.dumps(
201 {
202 "event": "loop_outcome",
203 "loop": params.loop.value,
204 "repo": params.target.repo.slug,
205 "package": outcome.candidate.package,
206 "from": str(outcome.candidate.current),
207 "to": str(outcome.candidate.target),
208 "changelog": outcome.verdict.kind,
209 "ci": outcome.ci.kind,
210 "ci_passed": outcome.ci_passed,
211 "pr": outcome.pr.number,
212 "pr_url": outcome.pr.url,
213 }
214 )
215 )
216 
217 
218@activity.defn
219async def dispatch_bump(params: DispatchInput) -> None:
220 """Start the bump loop for a candidate (idempotent per bump identity).
221 
222 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
223 the bump's params — so the running workflow never reads config itself and an
224 in-flight bump keeps the value it was dispatched with.
225 """
226 from temporalio.common import WorkflowIDReusePolicy
227 from temporalio.exceptions import WorkflowAlreadyStartedError
228 
229 from froot.config.settings import BehaviorSettings
230 from froot.workflow.bump_workflow import BumpWorkflow
231 from froot.workflow.temporal_client import client, task_queue
232 from froot.workflow.types import BumpParams
233 
234 temporal = await client()
235 try:
236 await temporal.start_workflow(
237 BumpWorkflow.run,
238 BumpParams(
239 target=params.target,
240 candidate=params.candidate,
241 close_on_red=BehaviorSettings().close_on_red,
242 loop=params.loop,
243 ),
244 id=bump_workflow_id(params.target, params.candidate, params.loop),
245 task_queue=task_queue(),
246 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
247 )
248 except WorkflowAlreadyStartedError:
249 # This bump already has a loop (running or completed) — a no-op, so
250 # re-scanning never opens a second PR for the same bump.
251 return
252 
253 
254@activity.defn
255async def close_pull_request(params: CloseInput) -> None:
256 """Comment why, then close a red bump's PR and delete its branch.
257 
258 The note goes through the idempotent ``upsert_issue_comment`` and the close
259 itself is idempotent, so a retried close edits its comment in place and
260 never double-posts. The bump's record step still runs after this, so the red
261 outcome is logged either way.
262 """
263 from froot.adapters.github import GitHubForge
264 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
265 
266 forge = GitHubForge()
267 body = closed_on_red_comment(params.failing)
268 await forge.upsert_issue_comment(
269 params.target, params.pr.number, CLOSE_MARKER, body
270 )
271 await forge.close_pull_request(
272 params.target, params.pr.number, params.pr.branch
273 )
274 _log.info(
275 json.dumps(
276 {
277 "event": "pr_closed",
278 "loop": params.loop.value,
279 "reason": "ci_red",
280 "repo": params.target.repo.slug,
281 "pr": params.pr.number,
282 "pr_url": params.pr.url,
283 "failing": list(params.failing),
284 }
285 )
286 )
287 
288 
289@activity.defn
290async def reconcile_open_prs(params: ReconcileInput) -> int:
291 """Close this loop's PRs that a newer candidate or the base has overtaken.
292 
293 Self-contained: lists the repo's open PRs, re-derives this loop's live
294 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
295 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
296 loop's PRs to close, and closes each (deleting its branch). Scoped to the
297 loop's own branch namespace, so the two loops never reconcile each other's
298 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
299 """
300 from froot.adapters.github import GitHubForge
301 from froot.adapters.registry import package_manager_for
302 from froot.config.settings import BehaviorSettings
303 from froot.policy.compose import CLOSE_MARKER
304 from froot.policy.reconcile import reconciliations
305 
306 if not BehaviorSettings().reconcile:
307 return 0
308 
309 target, loop = params.target, params.loop
310 forge = GitHubForge()
311 package_manager = package_manager_for(target.ecosystem)
312 open_prs = await forge.list_open_pull_requests(target)
313 with tempfile.TemporaryDirectory() as tmp:
314 workspace = Path(tmp)
315 await forge.checkout(target, workspace)
316 candidates = await _select_candidates(
317 loop, target, package_manager, _manifest_dir(target, workspace)
318 )
319 closures = reconciliations(open_prs, candidates, loop)
320 for closure in closures:
321 await forge.upsert_issue_comment(
322 target, closure.pr.number, CLOSE_MARKER, closure.comment
323 )
324 await forge.close_pull_request(
325 target, closure.pr.number, closure.pr.branch
326 )
327 if closures:
328 _reconcile_log.info(
329 json.dumps(
330 {
331 "event": "reconcile",
332 "loop": loop.value,
333 "repo": target.repo.slug,
334 "closed": len(closures),
335 "prs": [closure.pr.number for closure in closures],
336 }
337 )
338 )
339 return len(closures)
340 
341 
342# ── The determinism reviewer (the transitive ring) ──────────────────────────
343@activity.defn
344async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
345 """List the repo's open PRs for the determinism reviewer to consider."""
346 from froot.adapters.github import GitHubForge
347 
348 return await GitHubForge().list_open_pull_requests(target)
349 
350 
351@activity.defn
352async def dispatch_pr_review(params: DispatchReviewInput) -> None:
353 """Start a PR's determinism review (idempotent per PR + head SHA)."""
354 from temporalio.common import WorkflowIDReusePolicy
355 from temporalio.exceptions import WorkflowAlreadyStartedError
356 
357 from froot.workflow.pr_review_workflow import PrReviewWorkflow
358 from froot.workflow.temporal_client import client, task_queue
359 
360 temporal = await client()
361 try:
362 await temporal.start_workflow(
363 PrReviewWorkflow.run,
364 PrReviewParams(target=params.target, pr=params.pr),
365 id=pr_review_workflow_id(
366 params.target, params.pr.number, params.pr.head_sha
367 ),
368 task_queue=task_queue(),
369 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
370 )
371 except WorkflowAlreadyStartedError:
372 # This (PR, head SHA) already has a review — a no-op, so re-polling
373 # never double-reviews the same commit.
374 return
375 
376 
377@activity.defn
378async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
379 """Check out the PR head and analyze the workflow surface for hazards."""
380 from froot.adapters.github import GitHubForge
381 from froot.adapters.source_tree import load_modules
382 from froot.config.settings import ReviewSettings
383 
384 forge = GitHubForge()
385 with tempfile.TemporaryDirectory() as tmp:
386 workspace = Path(tmp)
387 await forge.checkout_pull_request(
388 params.target, workspace, params.pr.number
389 )
390 # The ASTs and source lines are read into memory here, so the analysis
391 # below is unaffected by the workspace being cleaned up.
392 modules = load_modules(workspace)
393 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
394 
395 
396@activity.defn
397async def adjudicate_frontier(
398 params: AdjudicateInput,
399) -> tuple[FrontierVerdict, ...]:
400 """Run the model over each frontier item; return aligned verdicts."""
401 from froot.adapters.determinism_judge import DeterminismFrontierJudge
402 
403 judge = DeterminismFrontierJudge()
404 verdicts: list[FrontierVerdict] = []
405 for item in params.frontier:
406 verdicts.append(await judge.adjudicate(item))
407 return tuple(verdicts)
408 
409 
410@activity.defn
411async def post_review(params: PostReviewInput) -> str | None:
412 """Upsert the advisory comment (when there are findings); log the ledger."""
413 from froot.adapters.github import GitHubForge
414 
415 body = render_review_comment(params.findings, params.pr.head_sha)
416 url: str | None = None
417 if body is not None:
418 url = await GitHubForge().upsert_issue_comment(
419 params.target, params.pr.number, REVIEW_MARKER, body
420 )
421 _review_log.info(
422 json.dumps(
423 {
424 "event": "loop_outcome",
425 "loop": "determinism-review",
426 "repo": params.target.repo.slug,
427 "pr": params.pr.number,
428 "head_sha": params.pr.head_sha,
429 "findings": len(params.findings),
430 "rules": sorted({f.rule for f in params.findings}),
431 "comment_url": url,
432 }
433 )
434 )
435 return url

Reconcile (the stale-PR cleanup from Phase 1) is generalized to take the loop's candidates and a loop, and is scoped to that loop's branch namespace — so the dependency-patch sweep can never close a security PR, and vice versa.

src/froot/policy/reconcile.py · 120 lines
src/froot/policy/reconcile.py120 lines · Python
⋯ 49 lines hidden (lines 1–49)
1"""Decide which of a loop's own open PRs no longer deserve to stay open.
2 
3Branch names are per *version*, so a package whose target moves on (a new patch
4for dependency-patch, a newer advisory's fix for security-patch) ends up with a
5*second* open PR — the stale one is never closed by the propose path. This pure
6policy is the cleanup: given the repo's open PRs and the loop's current
7candidates (re-derived from the repo this tick), it returns the PRs to close,
8each with its note.
9 
10A PR is closed when it is **superseded** — the loop's current candidate for that
11package targets a newer version than the PR does, so the PR's bump is stale.
12(That also subsumes "the base already caught up": any candidate's ``current`` is
13below its ``target``, so a PR at or below ``current`` is below ``target`` too.)
14Matching is loop-scoped and fail-safe: a PR is attributed to a package only when
15its branch carries that loop's prefix *and* the remaining tail parses as a
16version — so the two loops never reconcile each other's PRs, slug collisions
17(``foo`` vs ``foo-bar``) resolve correctly, and a PR that matches nothing is
18deliberately left open. froot stores nothing; the set is re-derived each tick.
19"""
20 
21from __future__ import annotations
22 
23from typing import TYPE_CHECKING
24 
25from froot.domain.base import Frozen
26from froot.domain.loop import Loop
27from froot.domain.pull_request import PullRequestRef
28from froot.domain.version import Version
29from froot.policy.compose import CLOSE_MARKER
30from froot.policy.naming import branch_package_prefix
31from froot.result import Ok
32 
33if TYPE_CHECKING:
34 from froot.domain.candidate import Candidate
35 
36 
37class ReconcileClosure(Frozen):
38 """A PR reconcile decided to close, plus the note to leave on it.
39 
40 Attributes:
41 pr: The open PR to close (its branch is deleted with it).
42 comment: The human-facing reason, carrying :data:`CLOSE_MARKER` so the
43 close posts through the idempotent comment path.
44 """
45 
46 pr: PullRequestRef
47 comment: str
48 
49 
50def reconciliations(
51 open_prs: tuple[PullRequestRef, ...],
52 candidates: tuple[Candidate, ...],
53 loop: Loop = Loop.DEPENDENCY_PATCH,
54) -> tuple[ReconcileClosure, ...]:
55 """The loop's PRs to close this tick, derived from its current candidates.
56 
57 Args:
58 open_prs: Every open PR on the repo (other loops' and humans' branches
59 simply never match this loop's prefix).
60 candidates: This loop's current candidates (each carries the package and
61 the version the loop now targets).
62 loop: Which loop is reconciling — scopes the branch matching to its own
63 namespace.
64 
65 Returns:
66 One :class:`ReconcileClosure` per of this loop's PRs that targets a
67 version below the loop's current candidate for that package, in
68 PR-number order.
69 """
70 by_package = {candidate.package: candidate for candidate in candidates}
71 closures: list[ReconcileClosure] = []
72 for pr in sorted(open_prs, key=lambda p: p.number):
73 matched = _match_pr(pr, candidates, loop)
74 if matched is None:
75 continue
76 package, pr_target = matched
77 candidate = by_package[package]
78 if pr_target < candidate.target:
79 closures.append(
80 ReconcileClosure(
81 pr=pr, comment=_superseded_comment(candidate.target)
82 )
83 )
84 return tuple(closures)
⋯ 36 lines hidden (lines 85–120)
85 
86 
87def _match_pr(
88 pr: PullRequestRef, candidates: tuple[Candidate, ...], loop: Loop
89) -> tuple[str, Version] | None:
90 """The ``(package, target)`` a PR's branch is for, within this loop.
91 
92 Matches the PR's branch against each candidate's loop prefix and parses the
93 remainder as a version — so a branch maps to a package only when the
94 tail is a real version, which disambiguates packages whose slugs prefix one
95 another (``foo`` vs ``foo-bar``). The longest matching prefix wins, so a
96 pathological exact collision still resolves deterministically.
97 """
98 best: tuple[str, Version, int] | None = None
99 for candidate in candidates:
100 prefix = branch_package_prefix(candidate.package, loop)
101 if not pr.branch.value.startswith(prefix):
102 continue
103 match Version.parse(pr.branch.value[len(prefix) :]):
104 case Ok(version):
105 if best is None or len(prefix) > best[2]:
106 best = (candidate.package, version, len(prefix))
107 case _:
108 continue
109 return (best[0], best[1]) if best is not None else None
110 
111 
112def _superseded_comment(superseding: Version) -> str:
113 """The note for a PR a newer target has overtaken."""
114 return "\n".join(
115 (
116 CLOSE_MARKER,
117 f"froot closed this PR: a newer target ({superseding}) supersedes "
118 "it. The newer bump is being proposed in its place.",
119 )
120 )
How it's proven6🎬 Evidence

Fixtures for the logic, mock-HTTP for the adapter

tests/test_security_candidates.pytests/test_osv.pytests/test_e2e_workflow.py

The version logic — the part most worth getting right — is pinned by pure fixture tests: the holding-range pick, the max-of-fixes, an unfixed advisory dropped, a fix at-or-below installed dropped, the partial-fix honesty, a uv case.

tests/test_security_candidates.py · 127 lines
tests/test_security_candidates.py127 lines · Python
⋯ 7 lines hidden (lines 1–7)
1from __future__ import annotations
2 
3from froot.domain.ecosystem import Ecosystem
4from froot.policy.candidates import select_security_candidates
5from tests.support import make_advisory, make_installed, ver
6 
7 
8def test_partial_fix_is_proposed_and_says_others_remain():
9 # One advisory is fixable (1.4.3), one has no fix; froot still proposes the
10 # bump (it reduces the surface) but the justification must not imply it
11 # clears the unfixable one.
12 installed = (make_installed("left-pad", "1.4.2"),)
13 advisories = (
14 make_advisory("left-pad", "GHSA-fix", ranges=(("0", "1.4.3"),)),
15 make_advisory("left-pad", "GHSA-nofix", ranges=(("0", None),)),
16 )
17 (candidate,) = select_security_candidates(installed, advisories)
18 assert candidate.target == ver("1.4.3")
19 assert candidate.justification is not None
20 assert "GHSA-fix" in candidate.justification
21 assert "GHSA-nofix" not in candidate.justification
22 assert "no usable fix" in candidate.justification
23 
24 
25def test_uv_ecosystem_security_candidate():
26 installed = (make_installed("jinja2", "2.10.0", ecosystem=Ecosystem.UV),)
27 advisories = (
28 make_advisory(
29 "jinja2",
30 "GHSA-py",
31 ranges=(("0", "2.10.1"),),
32 ecosystem=Ecosystem.UV,
33 ),
34 )
35 (candidate,) = select_security_candidates(installed, advisories)
36 assert candidate.ecosystem is Ecosystem.UV
37 assert candidate.target == ver("2.10.1")
38 
39 
40def test_unparseable_introduced_bound_is_not_a_candidate():
41 # A lower bound that doesn't parse can't be reasoned about, so the range
42 # never matches and froot proposes nothing (conservative).
43 installed = (make_installed("left-pad", "1.4.2"),)
44 advisories = (
45 make_advisory("left-pad", "GHSA-bad", ranges=(("garbage", "1.4.3"),)),
46 )
47 assert select_security_candidates(installed, advisories) == ()
⋯ 80 lines hidden (lines 48–127)
48 
49 
50def test_single_advisory_targets_its_fix():
51 installed = (make_installed("left-pad", "1.4.2"),)
52 advisories = (
53 make_advisory("left-pad", "GHSA-1", ranges=(("0", "1.4.3"),)),
54 )
55 (candidate,) = select_security_candidates(installed, advisories)
56 assert candidate.package == "left-pad"
57 assert candidate.current == ver("1.4.2")
58 assert candidate.target == ver("1.4.3")
59 assert candidate.justification is not None
60 assert "GHSA-1" in candidate.justification
61 
62 
63def test_max_of_fixes_clears_all_advisories():
64 # Two advisories on one package fixed in 1.4.3 and 1.5.0; the bump reaches
65 # the higher one to clear both.
66 installed = (make_installed("left-pad", "1.4.2"),)
67 advisories = (
68 make_advisory("left-pad", "GHSA-1", ranges=(("0", "1.4.3"),)),
69 make_advisory("left-pad", "GHSA-2", ranges=(("0", "1.5.0"),)),
70 )
71 (candidate,) = select_security_candidates(installed, advisories)
72 assert candidate.target == ver("1.5.0")
73 assert candidate.justification is not None
74 assert "GHSA-1" in candidate.justification
75 assert "GHSA-2" in candidate.justification
76 
77 
78def test_picks_the_range_holding_the_installed_version():
79 # minimist-style: vulnerable in [0, 0.2.1) and [1.0.0, 1.2.3); 1.2.0 is in
80 # the second branch, so the fix is 1.2.3, not 0.2.1.
81 installed = (make_installed("minimist", "1.2.0"),)
82 advisories = (
83 make_advisory(
84 "minimist", "GHSA-x", ranges=(("0", "0.2.1"), ("1.0.0", "1.2.3"))
85 ),
86 )
87 (candidate,) = select_security_candidates(installed, advisories)
88 assert candidate.target == ver("1.2.3")
89 
90 
91def test_advisory_with_no_fix_is_dropped():
92 installed = (make_installed("left-pad", "1.4.2"),)
93 advisories = (
94 make_advisory("left-pad", "GHSA-nofix", ranges=(("0", None),)),
95 )
96 assert select_security_candidates(installed, advisories) == ()
97 
98 
99def test_fix_at_or_below_installed_is_not_a_candidate():
100 # The installed version is already past the fix → no range holds it → drop.
101 installed = (make_installed("left-pad", "1.4.2"),)
102 advisories = (
103 make_advisory("left-pad", "GHSA-old", ranges=(("0", "1.4.0"),)),
104 )
105 assert select_security_candidates(installed, advisories) == ()
106 
107 
108def test_package_without_advisories_yields_nothing():
109 installed = (make_installed("left-pad", "1.4.2"),)
110 assert select_security_candidates(installed, ()) == ()
111 
112 
113def test_aliases_named_in_justification_and_sorted_by_package():
114 installed = (
115 make_installed("zeta", "1.0.0"),
116 make_installed("alpha", "2.0.0"),
117 )
118 advisories = (
119 make_advisory(
120 "zeta", "GHSA-z", ranges=(("0", "1.0.1"),), aliases=("CVE-9",)
121 ),
122 make_advisory("alpha", "GHSA-a", ranges=(("0", "2.0.1"),)),
123 )
124 candidates = select_security_candidates(installed, advisories)
125 assert [c.package for c in candidates] == ["alpha", "zeta"]
126 assert candidates[1].justification is not None
127 assert "CVE-9" in candidates[1].justification

The OSV adapter itself — the two-call flow, the JSON shaping, the query/result alignment, the best-effort error path — is exercised against an in-memory httpx.MockTransport, no network. (This test was added in review: the adapter's advisories() had been left untested.)

tests/test_osv.py · 171 lines
tests/test_osv.py171 lines · Python
⋯ 41 lines hidden (lines 1–41)
1from __future__ import annotations
2 
3import httpx
4import pytest
5 
6from froot.adapters.osv import (
7 OsvAdvisorySource,
8 _advisory_from_record,
9 _osv_ecosystem,
10 _ranges_from_affected,
12from froot.domain.ecosystem import Ecosystem
13from tests.support import make_installed
14 
15_VULN_RECORD = {
16 "id": "GHSA-1",
17 "aliases": ["CVE-1"],
18 "affected": [
19 {
20 "package": {"name": "left-pad", "ecosystem": "npm"},
21 "ranges": [{"events": [{"introduced": "0"}, {"fixed": "1.4.3"}]}],
22 }
23 ],
25 
26 
27def _mock_httpx(monkeypatch: pytest.MonkeyPatch, handler) -> None:
28 """Route the adapter's AsyncClient through a MockTransport handler.
29 
30 The adapter calls ``httpx.AsyncClient(...)``, so patching the shared httpx
31 module's attribute reaches it (restored after the test by monkeypatch).
32 """
33 real = httpx.AsyncClient
34 
35 def factory(**kwargs):
36 kwargs.pop("transport", None)
37 return real(transport=httpx.MockTransport(handler), **kwargs)
38 
39 monkeypatch.setattr(httpx, "AsyncClient", factory)
40 
41 
42async def test_advisories_batches_then_fetches_and_shapes(
43 monkeypatch: pytest.MonkeyPatch,
44):
45 def handler(request: httpx.Request) -> httpx.Response:
46 if request.url.path == "/v1/querybatch":
47 # First package has a vuln; the second returns the empty {} slot —
48 # exercising the zip alignment between queries and results.
49 return httpx.Response(
50 200, json={"results": [{"vulns": [{"id": "GHSA-1"}]}, {}]}
51 )
52 if request.url.path == "/v1/vulns/GHSA-1":
53 return httpx.Response(200, json=_VULN_RECORD)
54 return httpx.Response(404)
55 
56 _mock_httpx(monkeypatch, handler)
57 installed = (
58 make_installed("left-pad", "1.4.2"),
59 make_installed("safe-pkg", "2.0.0"),
60 )
61 advisories = await OsvAdvisorySource().advisories(installed)
62 assert len(advisories) == 1
63 assert advisories[0].id == "GHSA-1"
64 assert advisories[0].package == "left-pad"
65 assert advisories[0].aliases == ("CVE-1",)
66 
67 
68async def test_advisories_empty_when_batch_fails(
69 monkeypatch: pytest.MonkeyPatch,
70):
71 def handler(request: httpx.Request) -> httpx.Response:
72 return httpx.Response(503) # OSV down
73 
74 _mock_httpx(monkeypatch, handler)
75 advisories = await OsvAdvisorySource().advisories(
76 (make_installed("left-pad", "1.4.2"),)
77 )
78 assert advisories == ()
⋯ 93 lines hidden (lines 79–171)
79 
80 
81async def test_advisories_empty_for_no_installed():
82 assert await OsvAdvisorySource().advisories(()) == ()
83 
84 
85def test_osv_ecosystem_strings():
86 assert _osv_ecosystem(Ecosystem.NPM) == "npm"
87 assert _osv_ecosystem(Ecosystem.UV) == "PyPI"
88 
89 
90def test_ranges_pairs_introduced_and_fixed():
91 affected = {
92 "ranges": [
93 {
94 "type": "SEMVER",
95 "events": [{"introduced": "1.0.0"}, {"fixed": "1.2.3"}],
96 }
97 ]
98 }
99 ranges = _ranges_from_affected(affected)
100 assert [(r.introduced, r.fixed) for r in ranges] == [("1.0.0", "1.2.3")]
101 
102 
103def test_ranges_splits_multiple_branches():
104 affected = {
105 "ranges": [
106 {
107 "events": [
108 {"introduced": "0"},
109 {"fixed": "0.2.1"},
110 {"introduced": "1.0.0"},
111 {"fixed": "1.2.3"},
112 ]
113 }
114 ]
115 }
116 ranges = _ranges_from_affected(affected)
117 assert [(r.introduced, r.fixed) for r in ranges] == [
118 ("0", "0.2.1"),
119 ("1.0.0", "1.2.3"),
120 ]
121 
122 
123def test_ranges_unfixed_span_has_no_fix():
124 affected = {"ranges": [{"events": [{"introduced": "0"}]}]}
125 ranges = _ranges_from_affected(affected)
126 assert ranges[0].fixed is None
127 
128 
129def test_advisory_keeps_only_the_matching_package():
130 record = {
131 "id": "GHSA-x",
132 "aliases": ["CVE-1"],
133 "affected": [
134 {
135 "package": {"name": "left-pad", "ecosystem": "npm"},
136 "ranges": [
137 {"events": [{"introduced": "0"}, {"fixed": "1.4.3"}]}
138 ],
139 },
140 {
141 "package": {"name": "other", "ecosystem": "npm"},
142 "ranges": [
143 {"events": [{"introduced": "0"}, {"fixed": "9.9.9"}]}
144 ],
145 },
146 ],
147 }
148 advisory = _advisory_from_record(
149 record, make_installed("left-pad", "1.4.2"), "npm"
150 )
151 assert advisory is not None
152 assert advisory.id == "GHSA-x"
153 assert advisory.aliases == ("CVE-1",)
154 assert [(r.introduced, r.fixed) for r in advisory.ranges] == [
155 ("0", "1.4.3")
156 ]
157 
158 
159def test_advisory_none_when_package_not_affected():
160 record = {
161 "id": "GHSA-x",
162 "affected": [
163 {"package": {"name": "other", "ecosystem": "npm"}, "ranges": []}
164 ],
165 }
166 assert (
167 _advisory_from_record(
168 record, make_installed("left-pad", "1.4.2"), "npm"
169 )
170 is None
171 )
tests/test_e2e_workflow.py · 184 lines
tests/test_e2e_workflow.py184 lines · Python
⋯ 124 lines hidden (lines 1–124)
1"""End-to-end: the real bump loop closing through the real activities.
2 
3Unlike ``test_bump_workflow`` (which mocks the activities to exercise just the
4spine), this wires the *real* activities — judge, open, check-ci, record, close
5— and only swaps the adapters underneath them for in-memory fakes. So it proves
6the whole chain closes: the durable workflow drives the pure state machine,
7which drives the real effect interpreters, which drive the ports. Both terminal
8shapes are covered — a green bump that records and stays open for the human, and
9a red bump that the loop closes (and whose branch it deletes) before recording.
10"""
11 
12from __future__ import annotations
13 
14from collections.abc import Callable
15from typing import Any
16 
17import pytest
18from temporalio.client import Client
19from temporalio.testing import WorkflowEnvironment
20from temporalio.worker import Worker
21 
22import froot.adapters.changelog_http as changelog_mod
23import froot.adapters.github as github_mod
24import froot.adapters.model_judge as model_mod
25import froot.adapters.registry as registry_mod
26from froot.domain.candidate import Candidate
27from froot.domain.changelog import Changelog, CleanVerdict
28from froot.domain.ci import CIFailed, CIPassed, CIPending
29from froot.domain.loop import Loop
30from froot.domain.outcome import LoopOutcome
31from froot.workflow import activities
32from froot.workflow.bump_workflow import BumpWorkflow
33from froot.workflow.runtime import DATA_CONVERTER
34from froot.workflow.types import BumpParams
35from tests.support import (
36 FakeChangelogSource,
37 FakeForge,
38 FakeJudge,
39 FakePackageManager,
40 make_candidate,
41 make_pr,
42 make_repo,
43 ver,
45 
46_TASK_QUEUE = "froot-e2e"
47# The real bump activities — only the adapters beneath them are faked.
48_REAL_ACTIVITIES: list[Callable[..., Any]] = [
49 activities.judge_changelog,
50 activities.open_pull_request,
51 activities.check_ci,
52 activities.record_outcome,
53 activities.close_pull_request,
55 
56 
57def _wire_fakes(monkeypatch: pytest.MonkeyPatch, fake: FakeForge) -> None:
58 """Point every adapter the bump activities reach at the shared fake."""
59 monkeypatch.setattr(github_mod, "GitHubForge", lambda: fake)
60 monkeypatch.setattr(
61 registry_mod,
62 "package_manager_for",
63 lambda ecosystem: FakePackageManager(),
64 )
65 monkeypatch.setattr(
66 changelog_mod,
67 "HttpChangelogSource",
68 lambda: FakeChangelogSource(
69 Changelog(package="left-pad", version=ver("1.4.3"), text="fix")
70 ),
71 )
72 monkeypatch.setattr(
73 model_mod,
74 "PydanticAiJudge",
75 lambda: FakeJudge(CleanVerdict(rationale="clean")),
76 )
77 
78 
79async def _pydantic_client(env: WorkflowEnvironment) -> Client:
80 config = env.client.config()
81 config["data_converter"] = DATA_CONVERTER
82 return Client(**config)
83 
84 
85async def _run(
86 *,
87 close_on_red: bool = True,
88 loop: Loop = Loop.DEPENDENCY_PATCH,
89 candidate: Candidate | None = None,
90) -> LoopOutcome:
91 async with await WorkflowEnvironment.start_time_skipping() as env:
92 client = await _pydantic_client(env)
93 async with Worker(
94 client,
95 task_queue=_TASK_QUEUE,
96 workflows=[BumpWorkflow],
97 activities=_REAL_ACTIVITIES,
98 ):
99 return await client.execute_workflow(
100 BumpWorkflow.run,
101 BumpParams(
102 target=make_repo(),
103 candidate=candidate or make_candidate(),
104 close_on_red=close_on_red,
105 loop=loop,
106 ),
107 id="bump-e2e",
108 task_queue=_TASK_QUEUE,
109 )
110 
111 
112async def test_green_bump_records_and_stays_open(
113 monkeypatch: pytest.MonkeyPatch,
114):
115 fake = FakeForge(opened_pr=make_pr(number=7), ci=CIPassed())
116 _wire_fakes(monkeypatch, fake)
117 outcome = await _run()
118 assert outcome.pr.number == 7
119 assert outcome.ci_passed
120 # The PR is recorded (labeled) and left open for the human — never closed.
121 assert set(fake.labeled or ()) == {"froot", "dependency-patch"}
122 assert fake.closed == []
123 
124 
125async def test_green_security_bump_records_with_security_labels(
126 monkeypatch: pytest.MonkeyPatch,
127):
128 # The whole chassis is loop-agnostic: a security bump (a minor, here) runs
129 # the same real activities to record; only the namespace differs.
130 fake = FakeForge(
131 opened_pr=make_pr(
132 number=9, branch="froot/security-patch/left-pad-1.5.0"
133 ),
134 ci=CIPassed(),
135 )
136 _wire_fakes(monkeypatch, fake)
137 # A security bump is often a minor — the generalized Candidate allows it.
138 candidate = make_candidate(
139 package="left-pad", current="1.4.2", target="1.5.0"
140 )
141 outcome = await _run(loop=Loop.SECURITY_PATCH, candidate=candidate)
142 assert outcome.ci_passed
143 assert set(fake.labeled or ()) == {"froot", "security-patch"}
144 assert fake.closed == []
⋯ 40 lines hidden (lines 145–184)
145 
146 
147async def test_green_bump_waits_through_pending_then_records(
148 monkeypatch: pytest.MonkeyPatch,
149):
150 # The real check_ci activity polls the fake through pending -> pending ->
151 # passed; the workflow's durable wait (time-skipped here) carries it.
152 fake = FakeForge(
153 opened_pr=make_pr(number=7),
154 ci_sequence=(CIPending(), CIPending(), CIPassed()),
155 )
156 _wire_fakes(monkeypatch, fake)
157 outcome = await _run()
158 assert outcome.ci_passed
159 assert fake.closed == []
160 
161 
162async def test_red_bump_closes_pr_and_records(
163 monkeypatch: pytest.MonkeyPatch,
164):
165 fake = FakeForge(opened_pr=make_pr(number=7), ci=CIFailed(failing=("ci",)))
166 _wire_fakes(monkeypatch, fake)
167 outcome = await _run()
168 assert isinstance(outcome.ci, CIFailed)
169 # Closed (with its branch deleted) AND recorded: the loop closed cleanly.
170 assert fake.closed == [7]
171 assert fake.deleted_branches == [make_pr(number=7).branch]
172 assert set(fake.labeled or ()) == {"froot", "dependency-patch"}
173 
174 
175async def test_red_bump_stays_open_when_close_on_red_off(
176 monkeypatch: pytest.MonkeyPatch,
177):
178 fake = FakeForge(opened_pr=make_pr(number=7), ci=CIFailed(failing=("ci",)))
179 _wire_fakes(monkeypatch, fake)
180 outcome = await _run(close_on_red=False)
181 assert isinstance(outcome.ci, CIFailed)
182 # Toggle off: recorded but left open, nothing closed.
183 assert fake.closed == []
184 assert set(fake.labeled or ()) == {"froot", "dependency-patch"}
The verdict7🎬 Claims scored on the empty diff

Did the chassis hold?

The claim is that a second loop is a signal, a candidate policy, and some namespacing — with the durable machinery untouched. Below, that claim is scored against the change, with the file that proves each row.

ClaimUpheld byWhere
A loop is one signal + one candidate policysecurity-patch adds an OSV adapter + select_security_candidates; the scan activity dispatches by loopadapters/osv.py, policy/candidates.py, workflow/activities.py
The pure spine never learns the looploop lives on the params; _execute hands it to the activities; the state-machine diff is emptyworkflow/bump_workflow.py, policy/state_machine.py (unchanged)
The candidate type generalized cleanlypatch-only moved to the policy; the type keeps stable-and-newer + a justificationdomain/candidate.py, policy/candidates.py
The two loops never collidebranch + label + id all namespace the loop; reconcile is loop-scopedpolicy/naming.py, policy/reconcile.py
Security targets clear the advisory, honestlymax-of-fixes; a partial fix says so; unparseable fixes droppedpolicy/candidates.py
Risky bumps are safe to attempta red security bump auto-closes (Phase 1's close-on-red, untouched)policy/state_machine.py (unchanged)
Six claims, six pieces of code-level evidence. make check is green: ruff, strict mypy, 251 tests, coverage ≥ 72%, both determinism gates.

Thirteen findings from the adversarial review are folded in, including a critical one a code reviewer would have missed: the dashboard generated only dependency-patch scan ids, so a running security scan showed as dead. It now derives liveness per (repo, loop). The partial-fix honesty (§4) and the OSV adapter test (§6) came from the same pass. One presentation follow-up is named in the PR: the dashboard's bump aggregates still combine both loops; the data is namespaced, the grouping is not yet.