What changed, and why

PR #20 made the dead-code loop un-export an unused symbol: strip the export, leave it module-private. That is the safe minimum. The fuller move is to delete a truly-dead symbol, and deletion needs an AST โ€” multi-line declarations, proper cleanup โ€” not a regex. froot's worker is Python and can't run a TypeScript AST, so the codemod runs where the deptry signal already runs: an e2b sandbox (Node + ts-morph).

The consequence is the interesting part. Until now the sandbox only ever read the checkout (deptry, a signal). This is the first time it mutates it and hands the edits back โ€” froot's first sandbox-as-action. That seam is the reason to care: it is the reusable plank the future fabrication loops ride. The agentic coding harness, when it comes, swaps the deterministic codemod for an LLM behind the same run-in-sandbox then apply-edits convention, on terrain that already works.

The module's own framing: action vs signal, and the degradation contract.

src/froot/adapters/codemod.py ยท 175 lines
src/froot/adapters/codemod.py175 lines ยท Python
1"""The dead-export codemod โ€” froot's first *action* run in the sandbox.
2 
3The dead-code loop's un-export is a one-line regex edit the worker does in
4process (see :func:`froot.policy.dead_source.unexport_line`). That is the safe
5minimum; the fuller action is to *delete* a truly-dead symbol, which needs an
6AST, not a regex โ€” multi-line declarations, proper cleanup. froot's worker is
7Python and cannot run a TypeScript AST, so the codemod runs where the deptry
8signal already runs: an e2b sandbox (Node + ``ts-morph``).
9 
10This is the first time the :class:`~froot.ports.protocols.Sandbox` hosts an
11*action* rather than a *signal* โ€” it mutates the checkout and returns the edits,
12where ``deptry`` only read it. The sandbox tears down on teardown and only
13*stdout* returns, so the codemod emits the changed files as a JSON map of
14``{path: content}``; the worker applies it to its checkout, with CI the oracle.
15It degrades like the deptry arm: with no ``FROOT_E2B_API_KEY`` (or any error) it
16returns ``False`` and the caller falls back to the in-worker un-export.
17 
18The codemod is conservative: for the flagged export it deletes the whole
19declaration only when nothing else in the file references the symbol; if the
20symbol is still used in its own file it just strips the ``export`` (knip already
21guarantees no *other* module imports it). The pure pieces (the script builder,
22the output parser) are fixture-tested; the Node codemod itself is validated by
23running it directly, the same posture as ``deptry`` (untested in CI, integration
24truth lives in the tool).
25"""
โ‹ฏ 150 lines hidden (lines 26โ€“175)
26 
27from __future__ import annotations
28 
29import json
30from typing import TYPE_CHECKING
31 
32from temporalio import activity
33 
34if TYPE_CHECKING:
35 from pathlib import Path
36 
37 from froot.domain.dead_source import DeadExport
38 from froot.ports.protocols import Sandbox
39 
40# The ts-morph codemod, validated against fixtures. Reads ``targets.json``
41# ({file, symbol}) and the checkout root from argv; deletes the unused export's
42# declaration if the file references it nowhere, else un-exports it; emits the
43# changed file as ``{relpath: new_text}`` (empty ``{}`` if the symbol is gone).
44_CODEMOD_JS = r"""const { Project, Node } = require("ts-morph");
45const fs = require("fs");
46const path = require("path");
47 
48const base = process.argv[2];
49const { file, symbol } = JSON.parse(fs.readFileSync("targets.json", "utf8"));
50const abs = path.join(base, file);
51 
52const project = new Project({ skipAddingFilesFromTsConfig: true });
53const sf = project.addSourceFileAtPath(abs);
54 
55const decls = sf.getExportedDeclarations().get(symbol);
56if (!decls || decls.length === 0) {
57 process.stdout.write("{}");
58 process.exit(0);
60const decl = decls[0];
61 
62const nameNode =
63 typeof decl.getNameNode === "function" ? decl.getNameNode() : null;
64let inFileRefs = 0;
65if (nameNode && typeof nameNode.findReferencesAsNodes === "function") {
66 for (const ref of nameNode.findReferencesAsNodes()) {
67 if (ref.getSourceFile() === sf && ref !== nameNode) inFileRefs++;
68 }
70 
71function exportableOf(d) {
72 return Node.isVariableDeclaration(d) ? d.getVariableStatement() : d;
74if (inFileRefs === 0) {
75 if (Node.isVariableDeclaration(decl)) {
76 const stmt = decl.getVariableStatement();
77 if (stmt && stmt.getDeclarations().length === 1) stmt.remove();
78 else decl.remove();
79 } else decl.remove();
80} else {
81 const ex = exportableOf(decl);
82 if (ex && typeof ex.setIsExported === "function") ex.setIsExported(false);
84const out = {}; out[file] = sf.getFullText();
85process.stdout.write(JSON.stringify(out));
86"""
87 
88# The sandbox script: capture the checkout (the script's initial cwd, where the
89# tar was extracted), install ts-morph off-checkout, write the codemod + the
90# targets, run it. All install noise goes to stderr so stdout is only the JSON
91# the worker parses (the same discipline as the deptry script).
92_SCRIPT = (
93 "set -e\n"
94 'WORK="$(pwd)"\n'
95 "mkdir -p /tmp/cm && cd /tmp/cm\n"
96 "cat > targets.json <<'TARGETS'\n"
97 "__TARGETS_JSON__\n"
98 "TARGETS\n"
99 "cat > codemod.js <<'CODEMOD'\n" + _CODEMOD_JS + "CODEMOD\n"
100 "npm init -y >/dev/null 2>&1\n"
101 "npm install ts-morph@24 >&2\n"
102 'node codemod.js "$WORK"\n'
104 
105 
106def build_codemod_script(file: str, symbol: str) -> str:
107 """The sandbox script that un-exports/deletes ``symbol`` in ``file``.
108 
109 ``file``/``symbol`` are embedded as JSON inside a literal heredoc, so a path
110 with shell metacharacters can never break out of the script.
111 """
112 targets = json.dumps({"file": file, "symbol": symbol})
113 return _SCRIPT.replace("__TARGETS_JSON__", targets)
114 
115 
116def parse_codemod_edits(stdout: str) -> dict[str, str]:
117 """Parse the codemod's ``{relpath: new_content}`` JSON (defensive).
118 
119 Empty or unparseable output, or a non-string entry, yields ``{}`` โ€” the
120 caller reads that as "the codemod did nothing" and falls back.
121 """
122 try:
123 data = json.loads(stdout.strip() or "{}")
124 except json.JSONDecodeError:
125 return {}
126 if not isinstance(data, dict):
127 return {}
128 return {
129 path: content
130 for path, content in data.items()
131 if isinstance(path, str) and isinstance(content, str)
132 }
133 
134 
135def _default_sandbox() -> Sandbox:
136 """Build the production sandbox backend (e2b), imported lazily."""
137 from froot.adapters.e2b_sandbox import E2bSandbox
138 
139 return E2bSandbox()
140 
141 
142async def apply_export_codemod(
143 workspace: Path, item: DeadExport, sandbox: Sandbox | None = None
144) -> bool:
145 """Run the codemod in the sandbox and apply its edits to ``workspace``.
146 
147 Returns ``True`` iff the sandbox ran cleanly and produced at least one file
148 edit (the deletion or un-export landed). Returns ``False`` when the sandbox
149 is unconfigured (no ``FROOT_E2B_API_KEY``), errors, exits non-zero, or finds
150 nothing โ€” the caller falls back to the in-worker un-export, so a missing
151 sandbox never blocks the loop.
152 """
153 sandbox = sandbox or _default_sandbox()
154 script = build_codemod_script(item.file, item.symbol)
155 try:
156 result = await sandbox.run(workspace, script)
157 except Exception as exc:
158 activity.logger.warning(
159 "export codemod sandbox unavailable for %s; falling back: %r",
160 item.symbol,
161 exc,
162 )
163 return False
164 if result.exit_code != 0:
165 activity.logger.warning(
166 "export codemod failed for %s (exit %d); falling back: %s",
167 item.symbol,
168 result.exit_code,
169 result.stderr[-200:],
170 )
171 return False
172 edits = parse_codemod_edits(result.stdout)
173 for rel, content in edits.items():
174 (workspace / rel).write_text(content)
175 return bool(edits)

The codemod

The whole AST decision is here. ts-morph loads only the flagged file (knip already guarantees no other module imports the symbol, so in-file references are all that can exist). If nothing in the file references the symbol, the whole declaration is removed; if it is still used in-file, only the export modifier is stripped. The changed file is emitted as a {path: content} JSON map on stdout โ€” the one thing that survives the sandbox teardown.

The validated ts-morph codemod: delete-if-dead, un-export-if-used-in-file.

src/froot/adapters/codemod.py ยท 175 lines
src/froot/adapters/codemod.py175 lines ยท Python
โ‹ฏ 43 lines hidden (lines 1โ€“43)
1"""The dead-export codemod โ€” froot's first *action* run in the sandbox.
2 
3The dead-code loop's un-export is a one-line regex edit the worker does in
4process (see :func:`froot.policy.dead_source.unexport_line`). That is the safe
5minimum; the fuller action is to *delete* a truly-dead symbol, which needs an
6AST, not a regex โ€” multi-line declarations, proper cleanup. froot's worker is
7Python and cannot run a TypeScript AST, so the codemod runs where the deptry
8signal already runs: an e2b sandbox (Node + ``ts-morph``).
9 
10This is the first time the :class:`~froot.ports.protocols.Sandbox` hosts an
11*action* rather than a *signal* โ€” it mutates the checkout and returns the edits,
12where ``deptry`` only read it. The sandbox tears down on teardown and only
13*stdout* returns, so the codemod emits the changed files as a JSON map of
14``{path: content}``; the worker applies it to its checkout, with CI the oracle.
15It degrades like the deptry arm: with no ``FROOT_E2B_API_KEY`` (or any error) it
16returns ``False`` and the caller falls back to the in-worker un-export.
17 
18The codemod is conservative: for the flagged export it deletes the whole
19declaration only when nothing else in the file references the symbol; if the
20symbol is still used in its own file it just strips the ``export`` (knip already
21guarantees no *other* module imports it). The pure pieces (the script builder,
22the output parser) are fixture-tested; the Node codemod itself is validated by
23running it directly, the same posture as ``deptry`` (untested in CI, integration
24truth lives in the tool).
25"""
26 
27from __future__ import annotations
28 
29import json
30from typing import TYPE_CHECKING
31 
32from temporalio import activity
33 
34if TYPE_CHECKING:
35 from pathlib import Path
36 
37 from froot.domain.dead_source import DeadExport
38 from froot.ports.protocols import Sandbox
39 
40# The ts-morph codemod, validated against fixtures. Reads ``targets.json``
41# ({file, symbol}) and the checkout root from argv; deletes the unused export's
42# declaration if the file references it nowhere, else un-exports it; emits the
43# changed file as ``{relpath: new_text}`` (empty ``{}`` if the symbol is gone).
44_CODEMOD_JS = r"""const { Project, Node } = require("ts-morph");
45const fs = require("fs");
46const path = require("path");
47 
48const base = process.argv[2];
49const { file, symbol } = JSON.parse(fs.readFileSync("targets.json", "utf8"));
50const abs = path.join(base, file);
51 
52const project = new Project({ skipAddingFilesFromTsConfig: true });
53const sf = project.addSourceFileAtPath(abs);
54 
55const decls = sf.getExportedDeclarations().get(symbol);
56if (!decls || decls.length === 0) {
57 process.stdout.write("{}");
58 process.exit(0);
60const decl = decls[0];
61 
62const nameNode =
63 typeof decl.getNameNode === "function" ? decl.getNameNode() : null;
64let inFileRefs = 0;
65if (nameNode && typeof nameNode.findReferencesAsNodes === "function") {
66 for (const ref of nameNode.findReferencesAsNodes()) {
67 if (ref.getSourceFile() === sf && ref !== nameNode) inFileRefs++;
68 }
70 
71function exportableOf(d) {
72 return Node.isVariableDeclaration(d) ? d.getVariableStatement() : d;
74if (inFileRefs === 0) {
75 if (Node.isVariableDeclaration(decl)) {
76 const stmt = decl.getVariableStatement();
77 if (stmt && stmt.getDeclarations().length === 1) stmt.remove();
78 else decl.remove();
79 } else decl.remove();
80} else {
81 const ex = exportableOf(decl);
82 if (ex && typeof ex.setIsExported === "function") ex.setIsExported(false);
84const out = {}; out[file] = sf.getFullText();
85process.stdout.write(JSON.stringify(out));
86"""
โ‹ฏ 89 lines hidden (lines 87โ€“175)
87 
88# The sandbox script: capture the checkout (the script's initial cwd, where the
89# tar was extracted), install ts-morph off-checkout, write the codemod + the
90# targets, run it. All install noise goes to stderr so stdout is only the JSON
91# the worker parses (the same discipline as the deptry script).
92_SCRIPT = (
93 "set -e\n"
94 'WORK="$(pwd)"\n'
95 "mkdir -p /tmp/cm && cd /tmp/cm\n"
96 "cat > targets.json <<'TARGETS'\n"
97 "__TARGETS_JSON__\n"
98 "TARGETS\n"
99 "cat > codemod.js <<'CODEMOD'\n" + _CODEMOD_JS + "CODEMOD\n"
100 "npm init -y >/dev/null 2>&1\n"
101 "npm install ts-morph@24 >&2\n"
102 'node codemod.js "$WORK"\n'
104 
105 
106def build_codemod_script(file: str, symbol: str) -> str:
107 """The sandbox script that un-exports/deletes ``symbol`` in ``file``.
108 
109 ``file``/``symbol`` are embedded as JSON inside a literal heredoc, so a path
110 with shell metacharacters can never break out of the script.
111 """
112 targets = json.dumps({"file": file, "symbol": symbol})
113 return _SCRIPT.replace("__TARGETS_JSON__", targets)
114 
115 
116def parse_codemod_edits(stdout: str) -> dict[str, str]:
117 """Parse the codemod's ``{relpath: new_content}`` JSON (defensive).
118 
119 Empty or unparseable output, or a non-string entry, yields ``{}`` โ€” the
120 caller reads that as "the codemod did nothing" and falls back.
121 """
122 try:
123 data = json.loads(stdout.strip() or "{}")
124 except json.JSONDecodeError:
125 return {}
126 if not isinstance(data, dict):
127 return {}
128 return {
129 path: content
130 for path, content in data.items()
131 if isinstance(path, str) and isinstance(content, str)
132 }
133 
134 
135def _default_sandbox() -> Sandbox:
136 """Build the production sandbox backend (e2b), imported lazily."""
137 from froot.adapters.e2b_sandbox import E2bSandbox
138 
139 return E2bSandbox()
140 
141 
142async def apply_export_codemod(
143 workspace: Path, item: DeadExport, sandbox: Sandbox | None = None
144) -> bool:
145 """Run the codemod in the sandbox and apply its edits to ``workspace``.
146 
147 Returns ``True`` iff the sandbox ran cleanly and produced at least one file
148 edit (the deletion or un-export landed). Returns ``False`` when the sandbox
149 is unconfigured (no ``FROOT_E2B_API_KEY``), errors, exits non-zero, or finds
150 nothing โ€” the caller falls back to the in-worker un-export, so a missing
151 sandbox never blocks the loop.
152 """
153 sandbox = sandbox or _default_sandbox()
154 script = build_codemod_script(item.file, item.symbol)
155 try:
156 result = await sandbox.run(workspace, script)
157 except Exception as exc:
158 activity.logger.warning(
159 "export codemod sandbox unavailable for %s; falling back: %r",
160 item.symbol,
161 exc,
162 )
163 return False
164 if result.exit_code != 0:
165 activity.logger.warning(
166 "export codemod failed for %s (exit %d); falling back: %s",
167 item.symbol,
168 result.exit_code,
169 result.stderr[-200:],
170 )
171 return False
172 edits = parse_codemod_edits(result.stdout)
173 for rel, content in edits.items():
174 (workspace / rel).write_text(content)
175 return bool(edits)

The sandbox-as-action seam

The sandbox runs a script and returns stdout; the workdir is discarded on teardown. So the action convention is: the script mutates its copy and prints the edits, and the worker applies them to its checkout (the one it will push). The script captures the checkout root, installs ts-morph off to the side so it never pollutes the tree, writes the codemod and the target, and runs it โ€” all install noise to stderr so stdout is only the JSON.

The sandbox script. WORK=$(pwd) is the extracted checkout; stdout stays clean.

src/froot/adapters/codemod.py ยท 175 lines
src/froot/adapters/codemod.py175 lines ยท Python
โ‹ฏ 87 lines hidden (lines 1โ€“87)
1"""The dead-export codemod โ€” froot's first *action* run in the sandbox.
2 
3The dead-code loop's un-export is a one-line regex edit the worker does in
4process (see :func:`froot.policy.dead_source.unexport_line`). That is the safe
5minimum; the fuller action is to *delete* a truly-dead symbol, which needs an
6AST, not a regex โ€” multi-line declarations, proper cleanup. froot's worker is
7Python and cannot run a TypeScript AST, so the codemod runs where the deptry
8signal already runs: an e2b sandbox (Node + ``ts-morph``).
9 
10This is the first time the :class:`~froot.ports.protocols.Sandbox` hosts an
11*action* rather than a *signal* โ€” it mutates the checkout and returns the edits,
12where ``deptry`` only read it. The sandbox tears down on teardown and only
13*stdout* returns, so the codemod emits the changed files as a JSON map of
14``{path: content}``; the worker applies it to its checkout, with CI the oracle.
15It degrades like the deptry arm: with no ``FROOT_E2B_API_KEY`` (or any error) it
16returns ``False`` and the caller falls back to the in-worker un-export.
17 
18The codemod is conservative: for the flagged export it deletes the whole
19declaration only when nothing else in the file references the symbol; if the
20symbol is still used in its own file it just strips the ``export`` (knip already
21guarantees no *other* module imports it). The pure pieces (the script builder,
22the output parser) are fixture-tested; the Node codemod itself is validated by
23running it directly, the same posture as ``deptry`` (untested in CI, integration
24truth lives in the tool).
25"""
26 
27from __future__ import annotations
28 
29import json
30from typing import TYPE_CHECKING
31 
32from temporalio import activity
33 
34if TYPE_CHECKING:
35 from pathlib import Path
36 
37 from froot.domain.dead_source import DeadExport
38 from froot.ports.protocols import Sandbox
39 
40# The ts-morph codemod, validated against fixtures. Reads ``targets.json``
41# ({file, symbol}) and the checkout root from argv; deletes the unused export's
42# declaration if the file references it nowhere, else un-exports it; emits the
43# changed file as ``{relpath: new_text}`` (empty ``{}`` if the symbol is gone).
44_CODEMOD_JS = r"""const { Project, Node } = require("ts-morph");
45const fs = require("fs");
46const path = require("path");
47 
48const base = process.argv[2];
49const { file, symbol } = JSON.parse(fs.readFileSync("targets.json", "utf8"));
50const abs = path.join(base, file);
51 
52const project = new Project({ skipAddingFilesFromTsConfig: true });
53const sf = project.addSourceFileAtPath(abs);
54 
55const decls = sf.getExportedDeclarations().get(symbol);
56if (!decls || decls.length === 0) {
57 process.stdout.write("{}");
58 process.exit(0);
60const decl = decls[0];
61 
62const nameNode =
63 typeof decl.getNameNode === "function" ? decl.getNameNode() : null;
64let inFileRefs = 0;
65if (nameNode && typeof nameNode.findReferencesAsNodes === "function") {
66 for (const ref of nameNode.findReferencesAsNodes()) {
67 if (ref.getSourceFile() === sf && ref !== nameNode) inFileRefs++;
68 }
70 
71function exportableOf(d) {
72 return Node.isVariableDeclaration(d) ? d.getVariableStatement() : d;
74if (inFileRefs === 0) {
75 if (Node.isVariableDeclaration(decl)) {
76 const stmt = decl.getVariableStatement();
77 if (stmt && stmt.getDeclarations().length === 1) stmt.remove();
78 else decl.remove();
79 } else decl.remove();
80} else {
81 const ex = exportableOf(decl);
82 if (ex && typeof ex.setIsExported === "function") ex.setIsExported(false);
84const out = {}; out[file] = sf.getFullText();
85process.stdout.write(JSON.stringify(out));
86"""
87 
88# The sandbox script: capture the checkout (the script's initial cwd, where the
89# tar was extracted), install ts-morph off-checkout, write the codemod + the
90# targets, run it. All install noise goes to stderr so stdout is only the JSON
91# the worker parses (the same discipline as the deptry script).
92_SCRIPT = (
93 "set -e\n"
94 'WORK="$(pwd)"\n'
95 "mkdir -p /tmp/cm && cd /tmp/cm\n"
96 "cat > targets.json <<'TARGETS'\n"
97 "__TARGETS_JSON__\n"
98 "TARGETS\n"
99 "cat > codemod.js <<'CODEMOD'\n" + _CODEMOD_JS + "CODEMOD\n"
100 "npm init -y >/dev/null 2>&1\n"
101 "npm install ts-morph@24 >&2\n"
102 'node codemod.js "$WORK"\n'
104 
โ‹ฏ 71 lines hidden (lines 105โ€“175)
105 
106def build_codemod_script(file: str, symbol: str) -> str:
107 """The sandbox script that un-exports/deletes ``symbol`` in ``file``.
108 
109 ``file``/``symbol`` are embedded as JSON inside a literal heredoc, so a path
110 with shell metacharacters can never break out of the script.
111 """
112 targets = json.dumps({"file": file, "symbol": symbol})
113 return _SCRIPT.replace("__TARGETS_JSON__", targets)
114 
115 
116def parse_codemod_edits(stdout: str) -> dict[str, str]:
117 """Parse the codemod's ``{relpath: new_content}`` JSON (defensive).
118 
119 Empty or unparseable output, or a non-string entry, yields ``{}`` โ€” the
120 caller reads that as "the codemod did nothing" and falls back.
121 """
122 try:
123 data = json.loads(stdout.strip() or "{}")
124 except json.JSONDecodeError:
125 return {}
126 if not isinstance(data, dict):
127 return {}
128 return {
129 path: content
130 for path, content in data.items()
131 if isinstance(path, str) and isinstance(content, str)
132 }
133 
134 
135def _default_sandbox() -> Sandbox:
136 """Build the production sandbox backend (e2b), imported lazily."""
137 from froot.adapters.e2b_sandbox import E2bSandbox
138 
139 return E2bSandbox()
140 
141 
142async def apply_export_codemod(
143 workspace: Path, item: DeadExport, sandbox: Sandbox | None = None
144) -> bool:
145 """Run the codemod in the sandbox and apply its edits to ``workspace``.
146 
147 Returns ``True`` iff the sandbox ran cleanly and produced at least one file
148 edit (the deletion or un-export landed). Returns ``False`` when the sandbox
149 is unconfigured (no ``FROOT_E2B_API_KEY``), errors, exits non-zero, or finds
150 nothing โ€” the caller falls back to the in-worker un-export, so a missing
151 sandbox never blocks the loop.
152 """
153 sandbox = sandbox or _default_sandbox()
154 script = build_codemod_script(item.file, item.symbol)
155 try:
156 result = await sandbox.run(workspace, script)
157 except Exception as exc:
158 activity.logger.warning(
159 "export codemod sandbox unavailable for %s; falling back: %r",
160 item.symbol,
161 exc,
162 )
163 return False
164 if result.exit_code != 0:
165 activity.logger.warning(
166 "export codemod failed for %s (exit %d); falling back: %s",
167 item.symbol,
168 result.exit_code,
169 result.stderr[-200:],
170 )
171 return False
172 edits = parse_codemod_edits(result.stdout)
173 for rel, content in edits.items():
174 (workspace / rel).write_text(content)
175 return bool(edits)

apply_export_codemod is the orchestration: build the script, run it in the sandbox, and on a clean run write each returned file back into the worker's checkout. It returns whether anything landed โ€” the signal the caller uses to decide between the codemod and the fallback.

The pure parser, then the run-then-apply orchestration; every non-clean path returns False.

src/froot/adapters/codemod.py ยท 175 lines
src/froot/adapters/codemod.py175 lines ยท Python
โ‹ฏ 115 lines hidden (lines 1โ€“115)
1"""The dead-export codemod โ€” froot's first *action* run in the sandbox.
2 
3The dead-code loop's un-export is a one-line regex edit the worker does in
4process (see :func:`froot.policy.dead_source.unexport_line`). That is the safe
5minimum; the fuller action is to *delete* a truly-dead symbol, which needs an
6AST, not a regex โ€” multi-line declarations, proper cleanup. froot's worker is
7Python and cannot run a TypeScript AST, so the codemod runs where the deptry
8signal already runs: an e2b sandbox (Node + ``ts-morph``).
9 
10This is the first time the :class:`~froot.ports.protocols.Sandbox` hosts an
11*action* rather than a *signal* โ€” it mutates the checkout and returns the edits,
12where ``deptry`` only read it. The sandbox tears down on teardown and only
13*stdout* returns, so the codemod emits the changed files as a JSON map of
14``{path: content}``; the worker applies it to its checkout, with CI the oracle.
15It degrades like the deptry arm: with no ``FROOT_E2B_API_KEY`` (or any error) it
16returns ``False`` and the caller falls back to the in-worker un-export.
17 
18The codemod is conservative: for the flagged export it deletes the whole
19declaration only when nothing else in the file references the symbol; if the
20symbol is still used in its own file it just strips the ``export`` (knip already
21guarantees no *other* module imports it). The pure pieces (the script builder,
22the output parser) are fixture-tested; the Node codemod itself is validated by
23running it directly, the same posture as ``deptry`` (untested in CI, integration
24truth lives in the tool).
25"""
26 
27from __future__ import annotations
28 
29import json
30from typing import TYPE_CHECKING
31 
32from temporalio import activity
33 
34if TYPE_CHECKING:
35 from pathlib import Path
36 
37 from froot.domain.dead_source import DeadExport
38 from froot.ports.protocols import Sandbox
39 
40# The ts-morph codemod, validated against fixtures. Reads ``targets.json``
41# ({file, symbol}) and the checkout root from argv; deletes the unused export's
42# declaration if the file references it nowhere, else un-exports it; emits the
43# changed file as ``{relpath: new_text}`` (empty ``{}`` if the symbol is gone).
44_CODEMOD_JS = r"""const { Project, Node } = require("ts-morph");
45const fs = require("fs");
46const path = require("path");
47 
48const base = process.argv[2];
49const { file, symbol } = JSON.parse(fs.readFileSync("targets.json", "utf8"));
50const abs = path.join(base, file);
51 
52const project = new Project({ skipAddingFilesFromTsConfig: true });
53const sf = project.addSourceFileAtPath(abs);
54 
55const decls = sf.getExportedDeclarations().get(symbol);
56if (!decls || decls.length === 0) {
57 process.stdout.write("{}");
58 process.exit(0);
60const decl = decls[0];
61 
62const nameNode =
63 typeof decl.getNameNode === "function" ? decl.getNameNode() : null;
64let inFileRefs = 0;
65if (nameNode && typeof nameNode.findReferencesAsNodes === "function") {
66 for (const ref of nameNode.findReferencesAsNodes()) {
67 if (ref.getSourceFile() === sf && ref !== nameNode) inFileRefs++;
68 }
70 
71function exportableOf(d) {
72 return Node.isVariableDeclaration(d) ? d.getVariableStatement() : d;
74if (inFileRefs === 0) {
75 if (Node.isVariableDeclaration(decl)) {
76 const stmt = decl.getVariableStatement();
77 if (stmt && stmt.getDeclarations().length === 1) stmt.remove();
78 else decl.remove();
79 } else decl.remove();
80} else {
81 const ex = exportableOf(decl);
82 if (ex && typeof ex.setIsExported === "function") ex.setIsExported(false);
84const out = {}; out[file] = sf.getFullText();
85process.stdout.write(JSON.stringify(out));
86"""
87 
88# The sandbox script: capture the checkout (the script's initial cwd, where the
89# tar was extracted), install ts-morph off-checkout, write the codemod + the
90# targets, run it. All install noise goes to stderr so stdout is only the JSON
91# the worker parses (the same discipline as the deptry script).
92_SCRIPT = (
93 "set -e\n"
94 'WORK="$(pwd)"\n'
95 "mkdir -p /tmp/cm && cd /tmp/cm\n"
96 "cat > targets.json <<'TARGETS'\n"
97 "__TARGETS_JSON__\n"
98 "TARGETS\n"
99 "cat > codemod.js <<'CODEMOD'\n" + _CODEMOD_JS + "CODEMOD\n"
100 "npm init -y >/dev/null 2>&1\n"
101 "npm install ts-morph@24 >&2\n"
102 'node codemod.js "$WORK"\n'
104 
105 
106def build_codemod_script(file: str, symbol: str) -> str:
107 """The sandbox script that un-exports/deletes ``symbol`` in ``file``.
108 
109 ``file``/``symbol`` are embedded as JSON inside a literal heredoc, so a path
110 with shell metacharacters can never break out of the script.
111 """
112 targets = json.dumps({"file": file, "symbol": symbol})
113 return _SCRIPT.replace("__TARGETS_JSON__", targets)
114 
115 
116def parse_codemod_edits(stdout: str) -> dict[str, str]:
117 """Parse the codemod's ``{relpath: new_content}`` JSON (defensive).
118 
119 Empty or unparseable output, or a non-string entry, yields ``{}`` โ€” the
120 caller reads that as "the codemod did nothing" and falls back.
121 """
122 try:
123 data = json.loads(stdout.strip() or "{}")
124 except json.JSONDecodeError:
125 return {}
126 if not isinstance(data, dict):
127 return {}
128 return {
129 path: content
130 for path, content in data.items()
131 if isinstance(path, str) and isinstance(content, str)
132 }
133 
134 
135def _default_sandbox() -> Sandbox:
136 """Build the production sandbox backend (e2b), imported lazily."""
137 from froot.adapters.e2b_sandbox import E2bSandbox
138 
139 return E2bSandbox()
140 
141 
142async def apply_export_codemod(
143 workspace: Path, item: DeadExport, sandbox: Sandbox | None = None
144) -> bool:
145 """Run the codemod in the sandbox and apply its edits to ``workspace``.
146 
147 Returns ``True`` iff the sandbox ran cleanly and produced at least one file
148 edit (the deletion or un-export landed). Returns ``False`` when the sandbox
149 is unconfigured (no ``FROOT_E2B_API_KEY``), errors, exits non-zero, or finds
150 nothing โ€” the caller falls back to the in-worker un-export, so a missing
151 sandbox never blocks the loop.
152 """
153 sandbox = sandbox or _default_sandbox()
154 script = build_codemod_script(item.file, item.symbol)
155 try:
156 result = await sandbox.run(workspace, script)
157 except Exception as exc:
158 activity.logger.warning(
159 "export codemod sandbox unavailable for %s; falling back: %r",
160 item.symbol,
161 exc,
162 )
163 return False
164 if result.exit_code != 0:
165 activity.logger.warning(
166 "export codemod failed for %s (exit %d); falling back: %s",
167 item.symbol,
168 result.exit_code,
169 result.stderr[-200:],
170 )
171 return False
172 edits = parse_codemod_edits(result.stdout)
173 for rel, content in edits.items():
174 (workspace / rel).write_text(content)
175 return bool(edits)

The action: codemod, or fall back

The open-PR activity's DeadExport arm tries the codemod first; if the sandbox is unconfigured (no key), errors, exits non-zero, or finds nothing, it falls back to the in-worker regex un-export from #20. So a missing sandbox never blocks the loop โ€” exactly the degradation the deptry signal already uses. CI is the oracle for whichever edit lands.

Prefer the codemod; fall back to the in-worker un-export.

src/froot/workflow/activities.py ยท 927 lines
src/froot/workflow/activities.py927 lines ยท Python
โ‹ฏ 413 lines hidden (lines 1โ€“413)
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.a11y import A11yAnalysis, A11yVerdict
22from froot.domain.candidate import Candidate
23from froot.domain.changelog import (
24 ChangelogVerdict,
25 CleanVerdict,
26 UnknownVerdict,
28from froot.domain.ci import CIStatus
29from froot.domain.dead_source import DeadExport, DeadFile
30from froot.domain.determinism import AnalysisResult, FrontierVerdict
31from froot.domain.pull_request import PullRequestDraft, PullRequestRef
32from froot.domain.removal import Removal
33from froot.domain.repo import TargetRepo
34from froot.domain.work import WorkItem
35from froot.policy.a11y_comment import (
36 A11Y_MARKER,
37 render_a11y_comment,
38 should_post,
40from froot.policy.a11y_scan import scan_sources
41from froot.policy.compose import (
42 dead_export_pull_request_draft,
43 dead_file_pull_request_draft,
44 pr_labels,
45 pull_request_draft,
46 removal_pull_request_draft,
48from froot.policy.dead_source import unexport_line
49from froot.policy.determinism import analyze_workflow_surface
50from froot.policy.naming import (
51 branch_name,
52 bump_workflow_id,
53 pr_a11y_review_workflow_id,
54 pr_review_workflow_id,
56from froot.policy.review_comment import (
57 REVIEW_MARKER,
58 render_review_comment,
60from froot.policy.review_comment import (
61 should_post as should_post_review,
63from froot.workflow.types import (
64 AdjudicateA11yInput,
65 AdjudicateInput,
66 AutoMergeInput,
67 CiCheckInput,
68 CloseInput,
69 DispatchA11yInput,
70 DispatchInput,
71 DispatchReviewInput,
72 GateReviewInput,
73 GateSelfTestInput,
74 JudgeInput,
75 MergeInput,
76 OpenPrInput,
77 PostA11yInput,
78 PostReviewInput,
79 PrA11yReviewParams,
80 PrReviewParams,
81 ReconcileInput,
82 RecordInput,
83 ScanCandidatesInput,
85 
86if TYPE_CHECKING:
87 from froot.domain.loop import Loop
88 from froot.ports.protocols import PackageManager
89 
90_log = logging.getLogger("froot.outcome")
91_review_log = logging.getLogger("froot.review")
92_a11y_log = logging.getLogger("froot.a11y")
93_reconcile_log = logging.getLogger("froot.reconcile")
94_scan_log = logging.getLogger("froot.scan")
95_gate_log = logging.getLogger("froot.gate")
96 
97 
98def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
99 """The directory the manifest lives in (a monorepo subdir, or the root)."""
100 return workspace / target.manifest_dir if target.manifest_dir else workspace
101 
102 
103def _draft_for(params: OpenPrInput) -> PullRequestDraft:
104 """Build the PR draft for the work item โ€” bump vs removal compose.
105 
106 The PR-title verb is the loop's, resolved from its registered spec here (the
107 impure boundary) and passed into the pure draft builders.
108 """
109 from froot.loops import registry
110 
111 item = params.candidate
112 title_prefix = registry.commit_tail(params.loop).title_prefix
113 match item:
114 case Candidate():
115 return pull_request_draft(
116 params.target,
117 item,
118 params.verdict,
119 params.loop,
120 title_prefix=title_prefix,
121 )
122 case Removal():
123 return removal_pull_request_draft(
124 params.target, item, params.loop, title_prefix=title_prefix
125 )
126 case DeadFile():
127 return dead_file_pull_request_draft(
128 params.target, item, params.loop, title_prefix=title_prefix
129 )
130 case DeadExport():
131 return dead_export_pull_request_draft(
132 params.target, item, params.loop, title_prefix=title_prefix
133 )
134 assert_never(item)
135 
136 
137def _delete_dead_file(base: Path, item: DeadFile) -> None:
138 """Delete the unused file from the checkout (``push_branch`` stages it).
139 
140 Resolved against ``base`` โ€” the manifest dir the analyzer ran in, so the
141 path matches what the signal flagged. A missing file raises (the same
142 fail-loud as the bump action), never a silent no-op PR.
143 """
144 (base / item.path).unlink()
145 
146 
147def _apply_dead_export(base: Path, item: DeadExport) -> None:
148 """Strip the unused ``export`` from the file at the analyzer's line.
149 
150 Re-validates the line still declares ``symbol`` via the same pure transform
151 the signal narrowed with (:func:`~froot.policy.dead_source.unexport_line`);
152 a mismatch โ€” the source drifted under the signal โ€” raises rather than push
153 an empty diff, so the loop never opens a no-op PR.
154 """
155 path = base / item.file
156 lines = path.read_text().split("\n")
157 index = item.line - 1
158 if not 0 <= index < len(lines):
159 msg = f"{item.file}:{item.line} out of range for {item.symbol!r}"
160 raise RuntimeError(msg)
161 rewritten = unexport_line(lines[index], item.symbol)
162 if rewritten is None:
163 msg = (
164 f"{item.file}:{item.line} is no longer an un-exportable "
165 f"declaration of {item.symbol!r}"
166 )
167 raise RuntimeError(msg)
168 lines[index] = rewritten
169 path.write_text("\n".join(lines))
170 
171 
172async def _select_candidates(
173 loop: Loop,
174 target: TargetRepo,
175 package_manager: PackageManager,
176 manifest_dir: Path,
177) -> tuple[int, tuple[WorkItem, ...]]:
178 """Gather this loop's signal from the checkout and select its work items.
179 
180 The one genuinely per-loop seam: dependency-patch reads the available
181 upgrades and picks the highest patch; security-patch reads the installed,
182 asks OSV for advisories, and picks the lowest version clearing each;
183 dead-code reads the unused dependencies a static analyzer flags and vetoes
184 each with the safe-to-remove judge. The impure sources are lazy-imported per
185 arm so none drags another's stack into a sandbox. Each feeds a pure (or, for
186 dead-code, model-vetoed) selection.
187 
188 Returns ``(considered, items)`` โ€” ``considered`` is the size of the upstream
189 signal (available upgrades / advisories found / unused deps flagged) so the
190 scan can make its selectivity legible (how much was seen versus kept).
191 
192 The one genuinely per-loop body now lives in each loop's spec ``observe``
193 (see :mod:`froot.loops`); the spine looks the loop up and runs it, so this
194 selection seam needs no per-loop arm.
195 """
196 from froot.loops import registry
197 
198 return await registry.commit_tail(loop).observe(
199 target, package_manager, manifest_dir
200 )
201 
202 
203@activity.defn
204async def scan_candidates(
205 params: ScanCandidatesInput,
206) -> tuple[WorkItem, ...]:
207 """Check out the repo and select this loop's candidates.
208 
209 Emits the tick's selectivity โ€” how much upstream signal was considered
210 versus how much was kept โ€” as span attributes (when ``FROOT_OTEL`` is on)
211 and as a structured ``scan_tick`` log, so the signal stage is legible in the
212 run ledger even on a tick that proposes nothing.
213 """
214 from froot.adapters.github import GitHubForge
215 from froot.adapters.registry import package_manager_for
216 from froot.adapters.telemetry import set_span_attributes
217 
218 forge = GitHubForge()
219 package_manager = package_manager_for(params.target.ecosystem)
220 with tempfile.TemporaryDirectory() as tmp:
221 workspace = Path(tmp)
222 await forge.checkout(params.target, workspace)
223 considered, candidates = await _select_candidates(
224 params.loop,
225 params.target,
226 package_manager,
227 _manifest_dir(params.target, workspace),
228 )
229 selected = len(candidates)
230 dropped = max(considered - selected, 0)
231 set_span_attributes(
232 scan_loop=params.loop.value,
233 scan_repo=params.target.repo.slug,
234 scan_considered=considered,
235 scan_selected=selected,
236 scan_dropped=dropped,
237 )
238 _scan_log.info(
239 json.dumps(
240 {
241 "event": "scan_tick",
242 "loop": params.loop.value,
243 "repo": params.target.repo.slug,
244 "considered": considered,
245 "selected": selected,
246 "dropped": dropped,
247 }
248 )
249 )
250 return candidates
251 
252 
253@activity.defn
254async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
255 """Fetch the candidate's changelog and get the model's typed verdict.
256 
257 The model is froot's one thin, non-load-bearing judgment: a clean verdict
258 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
259 erroring must not stall the spine. A judge failure degrades to
260 ``UnknownVerdict`` โ€” the bump proceeds, the human still gets the PR, and the
261 dashboard records the verdict as unknown โ€” rather than failing (and then
262 retrying) the activity. Only the model call is guarded; the fetch is already
263 best-effort (returns ``None``, not an exception). The loop selects what the
264 model is asked (clean-patch vs breaking-change-on-a-security-bump).
265 """
266 from froot.adapters.changelog_http import HttpChangelogSource
267 from froot.adapters.model_judge import PydanticAiJudge
268 
269 candidate = params.candidate
270 if isinstance(candidate, Removal | DeadFile | DeadExport):
271 # A signal-judged kind (removal / dead file / dead export) already
272 # cleared its safe-to-remove veto at scan; there is no changelog to
273 # read, so carry that conclusion straight into the PR framing (the
274 # rationale the veto recorded on the work item).
275 return CleanVerdict(
276 rationale=candidate.justification or "unused; safe to remove"
277 )
278 changelog = await HttpChangelogSource().fetch(candidate)
279 if changelog is None:
280 return UnknownVerdict(rationale="No changelog could be fetched.")
281 try:
282 return await PydanticAiJudge().judge(changelog, params.loop)
283 except Exception as exc:
284 activity.logger.warning(
285 "changelog judge unavailable for %s; degrading to unknown: %r",
286 params.candidate.subject,
287 exc,
288 )
289 return UnknownVerdict(
290 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
291 )
292 
293 
294@activity.defn
295async def gate_review(params: GateReviewInput) -> ChangelogVerdict:
296 """Independently deep-review a bump at the gate; fail-closed to a hold.
297 
298 The fourth trust leg (ยง3.7): a second, adversarial model pass over the
299 changelog, run only when a bump is about to auto-merge. ``clean`` approves
300 the merge; ``risky``/``unknown`` hold the PR for the human. Fail-CLOSED โ€” a
301 missing changelog or a model error returns a non-clean verdict, so an
302 unreviewable bump never merges unattended. This is the opposite disposition
303 from :func:`judge_changelog` (which degrades-to-proceed): here the safe
304 direction is to hold, and a non-clean verdict already means hold.
305 """
306 from froot.adapters.changelog_http import HttpChangelogSource
307 from froot.adapters.model_judge import PydanticAiJudge
308 
309 candidate = params.candidate
310 verdict: ChangelogVerdict
311 if isinstance(candidate, Removal):
312 # The fourth leg for a removal: independently re-judge that it is safe
313 # to remove (the same check the scan veto ran). No changelog to read;
314 # fail-CLOSED to a hold on a model error, like the bump path.
315 try:
316 verdict = await PydanticAiJudge().judge_removal(candidate)
317 except Exception as exc:
318 activity.logger.warning(
319 "safe-to-remove gate review unavailable for %s; holding: %r",
320 candidate.subject,
321 exc,
322 )
323 verdict = UnknownVerdict(
324 rationale=(
325 f"Removal reviewer unavailable ({type(exc).__name__})."
326 )
327 )
328 elif isinstance(candidate, DeadFile | DeadExport):
329 # The fourth leg for a dead file / unused export: independently re-judge
330 # the delete / un-export is safe (the scan veto's check). No changelog
331 # to read; fail-CLOSED to a hold on a model error, like the bump path.
332 try:
333 verdict = await PydanticAiJudge().judge_dead_source(candidate)
334 except Exception as exc:
335 activity.logger.warning(
336 "dead-source gate review unavailable for %s; holding: %r",
337 candidate.subject,
338 exc,
339 )
340 verdict = UnknownVerdict(
341 rationale=(
342 f"Dead-source reviewer unavailable ({type(exc).__name__})."
343 )
344 )
345 else:
346 changelog = await HttpChangelogSource().fetch(candidate)
347 if changelog is None:
348 verdict = UnknownVerdict(
349 rationale="No changelog to review; holding (fail-closed)."
350 )
351 else:
352 try:
353 verdict = await PydanticAiJudge().gate_review(
354 changelog, params.loop
355 )
356 except Exception as exc:
357 activity.logger.warning(
358 "gate reviewer unavailable for %s; holding: %r",
359 candidate.package,
360 exc,
361 )
362 verdict = UnknownVerdict(
363 rationale=(
364 f"Gate reviewer unavailable ({type(exc).__name__})."
365 )
366 )
367 _gate_log.info(
368 json.dumps(
369 {
370 "event": "gate_review",
371 "loop": params.loop.value,
372 "pr": params.pr.number,
373 "pr_url": params.pr.url,
374 "package": params.candidate.subject,
375 "verdict": verdict.kind,
376 "approved": verdict.kind == "clean",
377 }
378 )
379 )
380 return verdict
381 
382 
383@activity.defn
384async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
385 """Apply the work item's edit and open (idempotently) its PR.
386 
387 The one place the action differs by work-item kind: a bump regenerates the
388 lockfile at the target version; a removal deletes a dependency (both
389 lockfile-only, no install or scripts); a dead file is deleted whole; a dead
390 export is stripped of its ``export``. Everything else (the dedup branch, the
391 checkout, the push, the open) is the same chassis, and CI is the oracle for
392 every kind.
393 """
394 from froot.adapters.github import GitHubForge
395 from froot.adapters.registry import package_manager_for
396 
397 forge = GitHubForge()
398 package_manager = package_manager_for(params.target.ecosystem)
399 branch = branch_name(params.candidate, params.loop)
400 existing = await forge.find_open_pull_request(params.target, branch)
401 if existing is not None:
402 return existing
403 draft = _draft_for(params)
404 item = params.candidate
405 with tempfile.TemporaryDirectory() as tmp:
406 workspace = Path(tmp)
407 await forge.checkout(params.target, workspace)
408 manifest_dir = _manifest_dir(params.target, workspace)
409 match item:
410 case Candidate():
411 await package_manager.apply_patch_bump(item, manifest_dir)
412 case Removal():
413 await package_manager.remove_dependency(item, manifest_dir)
414 case DeadFile():
415 _delete_dead_file(manifest_dir, item)
416 case DeadExport():
417 # Prefer the AST codemod in the sandbox (deletes a truly-dead
418 # symbol, un-exports one still used in-file); fall back to the
419 # in-worker regex un-export when no sandbox is configured.
420 from froot.adapters.codemod import apply_export_codemod
421 
422 if not await apply_export_codemod(manifest_dir, item):
423 _apply_dead_export(manifest_dir, item)
โ‹ฏ 504 lines hidden (lines 424โ€“927)
424 await forge.push_branch(workspace, branch, draft.title)
425 return await forge.open_pull_request(params.target, draft)
426 
427 
428@activity.defn
429async def check_ci(params: CiCheckInput) -> CIStatus:
430 """Read the repo's CI status for the PR's head commit (the oracle)."""
431 from froot.adapters.github import GitHubForge
432 
433 return await GitHubForge().ci_status(params.target, params.head_sha)
434 
435 
436@activity.defn
437async def record_outcome(params: RecordInput) -> None:
438 """Label the PR and log the run telemetry โ€” the signal-update.
439 
440 The labels carry the loop *and* the judgment environment (the judge model)
441 the PR was opened under, so the gate can count only the track record earned
442 under the current environment and reset it when the model changes (ยง3.7).
443 """
444 from froot.adapters.github import GitHubForge
445 from froot.config.settings import ModelSettings
446 from froot.policy.environment import env_label
447 
448 outcome = params.outcome
449 item = outcome.candidate
450 labels = (
451 *pr_labels(params.loop),
452 env_label(ModelSettings().ollama_model),
453 )
454 await GitHubForge().add_labels(params.target, outcome.pr.number, labels)
455 record = {
456 "event": "loop_outcome",
457 "loop": params.loop.value,
458 "repo": params.target.repo.slug,
459 "package": item.subject,
460 "changelog": outcome.verdict.kind,
461 "ci": outcome.ci.kind,
462 "ci_passed": outcome.ci_passed,
463 "pr": outcome.pr.number,
464 "pr_url": outcome.pr.url,
465 }
466 match item:
467 case Candidate():
468 record |= {
469 "action": "bump",
470 "from": str(item.current),
471 "to": str(item.target),
472 }
473 case Removal():
474 record |= {"action": "remove", "dev": item.dev}
475 case DeadFile():
476 record |= {"action": "remove_file", "path": item.path}
477 case DeadExport():
478 record |= {
479 "action": "unexport",
480 "file": item.file,
481 "symbol": item.symbol,
482 }
483 _log.info(json.dumps(record))
484 
485 
486@activity.defn
487async def dispatch_bump(params: DispatchInput) -> None:
488 """Start the bump loop for a candidate (idempotent per bump identity).
489 
490 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
491 the bump's params โ€” so the running workflow never reads config itself and an
492 in-flight bump keeps the value it was dispatched with.
493 """
494 from temporalio.common import WorkflowIDReusePolicy
495 from temporalio.exceptions import WorkflowAlreadyStartedError
496 
497 from froot.config.settings import BehaviorSettings
498 from froot.workflow.bump_workflow import BumpWorkflow
499 from froot.workflow.temporal_client import client, task_queue
500 from froot.workflow.types import BumpParams
501 
502 temporal = await client()
503 try:
504 await temporal.start_workflow(
505 BumpWorkflow.run,
506 BumpParams(
507 target=params.target,
508 candidate=params.candidate,
509 close_on_red=BehaviorSettings().close_on_red,
510 loop=params.loop,
511 ),
512 id=bump_workflow_id(params.target, params.candidate, params.loop),
513 task_queue=task_queue(),
514 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
515 )
516 except WorkflowAlreadyStartedError:
517 # This bump already has a loop (running or completed) โ€” a no-op, so
518 # re-scanning never opens a second PR for the same bump.
519 return
520 
521 
522@activity.defn
523async def auto_merge_eligible(params: AutoMergeInput) -> bool:
524 """Whether this (repo, loop) class has earned the auto-merge grant.
525 
526 Short-circuits to ``False`` for any repo a steward has not allowlisted (the
527 default), so the common case costs nothing. Otherwise it re-derives the
528 class's standing from the live GitHub history โ€” the same triangulated,
529 windowed, environment-scoped computation the dashboard's shadow gate shows
530 (``read_model.earned_now``) โ€” so the acting gate and the advisory panel can
531 never disagree. Best-effort: any read failure degrades to ``False`` (hold),
532 never an auto-merge.
533 """
534 from datetime import UTC, datetime
535 
536 from froot.config.settings import AutonomySettings, ModelSettings
537 from froot.dashboard import github_source, read_model
538 from froot.policy.environment import environment_slug
539 
540 policy = AutonomySettings().policy()
541 repo = params.target.repo.slug
542 if repo not in policy.allowlisted_repos:
543 return False
544 now = datetime.now(UTC)
545 prs, prs_error = await github_source.fetch((repo,))
546 if prs_error is not None:
547 return False # can't confirm the record -> hold, never merge blind
548 outcomes, _ = await github_source.fetch_outcomes(
549 (repo,), prs, now=now, window_days=policy.window_days
550 )
551 return read_model.earned_now(
552 now,
553 prs,
554 outcomes,
555 repo,
556 params.loop,
557 policy,
558 environment_slug(ModelSettings().ollama_model),
559 )
560 
561 
562@activity.defn
563async def merge_pull_request(params: MergeInput) -> None:
564 """Auto-merge an earned, clean+green bump's PR (the acting gate's write).
565 
566 Reached only after the pure machine confirmed clean+green and the class
567 earned the grant on an allowlisted repo. Passes the head SHA so GitHub
568 refuses the merge if the head moved since the gate decided.
569 """
570 from froot.adapters.github import GitHubForge
571 
572 await GitHubForge().merge_pull_request(
573 params.target, params.pr.number, head_sha=params.pr.head_sha
574 )
575 _log.info(
576 json.dumps(
577 {
578 "event": "pr_merged",
579 "loop": params.loop.value,
580 "reason": "auto_merge",
581 "repo": params.target.repo.slug,
582 "pr": params.pr.number,
583 "pr_url": params.pr.url,
584 }
585 )
586 )
587 
588 
589@activity.defn
590async def close_pull_request(params: CloseInput) -> None:
591 """Comment why, then close a red bump's PR and delete its branch.
592 
593 The note goes through the idempotent ``upsert_issue_comment`` and the close
594 itself is idempotent, so a retried close edits its comment in place and
595 never double-posts. The bump's record step still runs after this, so the red
596 outcome is logged either way.
597 """
598 from froot.adapters.github import GitHubForge
599 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
600 
601 forge = GitHubForge()
602 body = closed_on_red_comment(params.failing)
603 await forge.upsert_issue_comment(
604 params.target, params.pr.number, CLOSE_MARKER, body
605 )
606 await forge.close_pull_request(
607 params.target, params.pr.number, params.pr.branch
608 )
609 _log.info(
610 json.dumps(
611 {
612 "event": "pr_closed",
613 "loop": params.loop.value,
614 "reason": "ci_red",
615 "repo": params.target.repo.slug,
616 "pr": params.pr.number,
617 "pr_url": params.pr.url,
618 "failing": list(params.failing),
619 }
620 )
621 )
622 
623 
624@activity.defn
625async def reconcile_open_prs(params: ReconcileInput) -> int:
626 """Close this loop's PRs that a newer candidate or the base has overtaken.
627 
628 Self-contained: lists the repo's open PRs, re-derives this loop's live
629 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
630 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
631 loop's PRs to close, and closes each (deleting its branch). Scoped to the
632 loop's own branch namespace, so the two loops never reconcile each other's
633 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
634 """
635 from froot.adapters.github import GitHubForge
636 from froot.adapters.registry import package_manager_for
637 from froot.config.settings import BehaviorSettings
638 from froot.policy.compose import CLOSE_MARKER
639 from froot.policy.reconcile import reconciliations
640 
641 if not BehaviorSettings().reconcile:
642 return 0
643 # Reconcile is version-supersession cleanup, which only bump loops have: a
644 # removal carries no version to be overtaken. A loop that does not reconcile
645 # (dead-code) is skipped rather than re-running its signal (knip + the veto
646 # judge) every tick only to close nothing. (A removal-specific reconcile โ€”
647 # close when no longer unused โ€” is future work.) The trait is on the spec.
648 from froot.loops import registry
649 
650 if not registry.commit_tail(params.loop).reconciles:
651 return 0
652 
653 target, loop = params.target, params.loop
654 forge = GitHubForge()
655 package_manager = package_manager_for(target.ecosystem)
656 open_prs = await forge.list_open_pull_requests(target)
657 with tempfile.TemporaryDirectory() as tmp:
658 workspace = Path(tmp)
659 await forge.checkout(target, workspace)
660 _considered, candidates = await _select_candidates(
661 loop, target, package_manager, _manifest_dir(target, workspace)
662 )
663 closures = reconciliations(open_prs, candidates, loop)
664 for closure in closures:
665 await forge.upsert_issue_comment(
666 target, closure.pr.number, CLOSE_MARKER, closure.comment
667 )
668 await forge.close_pull_request(
669 target, closure.pr.number, closure.pr.branch
670 )
671 if closures:
672 _reconcile_log.info(
673 json.dumps(
674 {
675 "event": "reconcile",
676 "loop": loop.value,
677 "repo": target.repo.slug,
678 "closed": len(closures),
679 "prs": [closure.pr.number for closure in closures],
680 }
681 )
682 )
683 return len(closures)
684 
685 
686@activity.defn
687async def gate_selftest(params: GateSelfTestInput) -> tuple[str, ...]:
688 """Run the adversarial gate probe against the live policy; alarm on escape.
689 
690 The ยง2.11 deliberate disturbance for the acting gate: a battery of synthetic
691 known-bad class histories a healthy gate must refuse, scored against the
692 policy froot is *actually running* (config and all). Any escape โ€” a bad
693 class the live gate would grant โ€” is logged at ERROR (the alarm) so it
694 surfaces in telemetry the moment config drifts; a clean pass logs a
695 heartbeat at INFO. Returns the escaped scenario names. Pure compute,
696 repo-independent (the ``target``/``loop`` are only the log's context).
697 """
698 from froot.config.settings import AutonomySettings
699 from froot.policy.gate_probe import gate_escapes
700 
701 escaped = gate_escapes(AutonomySettings().policy())
702 record = json.dumps(
703 {
704 "event": "gate_selftest",
705 "loop": params.loop.value,
706 "repo": params.target.repo.slug,
707 "healthy": not escaped,
708 "escaped": list(escaped),
709 }
710 )
711 if escaped:
712 _gate_log.error(record) # an alarm: the gate would trust a bad class
713 else:
714 _gate_log.info(record)
715 return escaped
716 
717 
718# โ”€โ”€ The determinism reviewer (the transitive ring) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
719@activity.defn
720async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
721 """List the repo's open PRs for the determinism reviewer to consider."""
722 from froot.adapters.github import GitHubForge
723 
724 return await GitHubForge().list_open_pull_requests(target)
725 
726 
727@activity.defn
728async def dispatch_pr_review(params: DispatchReviewInput) -> None:
729 """Start a PR's determinism review (idempotent per PR + head SHA)."""
730 from temporalio.common import WorkflowIDReusePolicy
731 from temporalio.exceptions import WorkflowAlreadyStartedError
732 
733 from froot.workflow.pr_review_workflow import PrReviewWorkflow
734 from froot.workflow.temporal_client import client, task_queue
735 
736 temporal = await client()
737 try:
738 await temporal.start_workflow(
739 PrReviewWorkflow.run,
740 PrReviewParams(target=params.target, pr=params.pr),
741 id=pr_review_workflow_id(
742 params.target, params.pr.number, params.pr.head_sha
743 ),
744 task_queue=task_queue(),
745 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
746 )
747 except WorkflowAlreadyStartedError:
748 # This (PR, head SHA) already has a review โ€” a no-op, so re-polling
749 # never double-reviews the same commit.
750 return
751 
752 
753@activity.defn
754async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
755 """Check out the PR head and analyze the workflow surface for hazards."""
756 from froot.adapters.github import GitHubForge
757 from froot.adapters.source_tree import load_modules
758 from froot.config.settings import ReviewSettings
759 
760 forge = GitHubForge()
761 with tempfile.TemporaryDirectory() as tmp:
762 workspace = Path(tmp)
763 await forge.checkout_pull_request(
764 params.target, workspace, params.pr.number
765 )
766 # The ASTs and source lines are read into memory here, so the analysis
767 # below is unaffected by the workspace being cleaned up.
768 modules = load_modules(workspace)
769 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
770 
771 
772@activity.defn
773async def adjudicate_frontier(
774 params: AdjudicateInput,
775) -> tuple[FrontierVerdict, ...]:
776 """Run the model over each frontier item; return aligned verdicts."""
777 from froot.adapters.determinism_judge import DeterminismFrontierJudge
778 
779 judge = DeterminismFrontierJudge()
780 verdicts: list[FrontierVerdict] = []
781 for item in params.frontier:
782 verdicts.append(await judge.adjudicate(item))
783 return tuple(verdicts)
784 
785 
786@activity.defn
787async def post_review(params: PostReviewInput) -> str | None:
788 """Upsert (or clear) the advisory comment; log the ledger row.
789 
790 Posts when there are findings, or when a prior comment must be cleared to
791 "all clear" (true decay โ€” a PR whose hazards were fixed never keeps a stale
792 finding list). A clean PR with no prior comment stays silent.
793 """
794 from froot.adapters.github import GitHubForge
795 
796 forge = GitHubForge()
797 exists = await forge.find_marked_comment(
798 params.target, params.pr.number, REVIEW_MARKER
799 )
800 url: str | None = None
801 if should_post_review(
802 has_findings=bool(params.findings), comment_exists=exists
803 ):
804 body = render_review_comment(params.findings, params.pr.head_sha)
805 url = await forge.upsert_issue_comment(
806 params.target, params.pr.number, REVIEW_MARKER, body
807 )
808 _review_log.info(
809 json.dumps(
810 {
811 "event": "loop_outcome",
812 "loop": "determinism-review",
813 "repo": params.target.repo.slug,
814 "pr": params.pr.number,
815 "head_sha": params.pr.head_sha,
816 "findings": len(params.findings),
817 "rules": sorted({f.rule for f in params.findings}),
818 "comment_url": url,
819 }
820 )
821 )
822 return url
823 
824 
825# โ”€โ”€ The a11y reviewer (the source-level design-system ring) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
826@activity.defn
827async def scan_pr_a11y(params: PrA11yReviewParams) -> A11yAnalysis:
828 """Check out the PR head and scan its changed templates for a11y risks.
829 
830 Scoped to the PR's changed Vue/JSX templates (shift-left: review what came
831 in), so the advisory stays bounded and high-signal. The source lines are
832 read into memory in the activity, so the pure scan is unaffected by the
833 workspace being cleaned up.
834 """
835 from froot.adapters.github import GitHubForge
836 from froot.adapters.web_source import load_web_sources
837 
838 forge = GitHubForge()
839 changed = await forge.list_pull_request_files(
840 params.target, params.pr.number
841 )
842 with tempfile.TemporaryDirectory() as tmp:
843 workspace = Path(tmp)
844 await forge.checkout_pull_request(
845 params.target, workspace, params.pr.number
846 )
847 sources = load_web_sources(workspace, changed)
848 candidates = scan_sources(sources)
849 return A11yAnalysis(candidates=candidates, scanned_files=len(sources))
850 
851 
852@activity.defn
853async def adjudicate_a11y(
854 params: AdjudicateA11yInput,
855) -> tuple[A11yVerdict, ...]:
856 """Run the model over each flagged candidate; return aligned verdicts."""
857 from froot.adapters.a11y_judge import A11ySourceJudge
858 
859 judge = A11ySourceJudge()
860 verdicts: list[A11yVerdict] = []
861 for candidate in params.candidates:
862 verdicts.append(await judge.adjudicate(candidate))
863 return tuple(verdicts)
864 
865 
866@activity.defn
867async def post_a11y_review(params: PostA11yInput) -> str | None:
868 """Upsert (or clear) the advisory comment; log the ledger row.
869 
870 Posts when there are findings, or when a prior comment must be cleared to
871 "all clear" (true decay โ€” a PR whose gaps were fixed never keeps a stale
872 finding list, the bug the determinism reviewer leaves). A clean PR with
873 no prior comment stays silent, so a clean PR is never spammed.
874 """
875 from froot.adapters.github import GitHubForge
876 
877 forge = GitHubForge()
878 exists = await forge.find_marked_comment(
879 params.target, params.pr.number, A11Y_MARKER
880 )
881 url: str | None = None
882 if should_post(has_findings=bool(params.findings), comment_exists=exists):
883 body = render_a11y_comment(params.findings, params.pr.head_sha)
884 url = await forge.upsert_issue_comment(
885 params.target, params.pr.number, A11Y_MARKER, body
886 )
887 _a11y_log.info(
888 json.dumps(
889 {
890 "event": "loop_outcome",
891 "loop": "a11y-review",
892 "repo": params.target.repo.slug,
893 "pr": params.pr.number,
894 "head_sha": params.pr.head_sha,
895 "findings": len(params.findings),
896 "kinds": sorted({f.kind for f in params.findings}),
897 "comment_url": url,
898 }
899 )
900 )
901 return url
902 
903 
904@activity.defn
905async def dispatch_pr_a11y_review(params: DispatchA11yInput) -> None:
906 """Start a PR's a11y review (idempotent per PR + head SHA)."""
907 from temporalio.common import WorkflowIDReusePolicy
908 from temporalio.exceptions import WorkflowAlreadyStartedError
909 
910 from froot.workflow.pr_a11y_review_workflow import PrA11yReviewWorkflow
911 from froot.workflow.temporal_client import client, task_queue
912 
913 temporal = await client()
914 try:
915 await temporal.start_workflow(
916 PrA11yReviewWorkflow.run,
917 PrA11yReviewParams(target=params.target, pr=params.pr),
918 id=pr_a11y_review_workflow_id(
919 params.target, params.pr.number, params.pr.head_sha
920 ),
921 task_queue=task_queue(),
922 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
923 )
924 except WorkflowAlreadyStartedError:
925 # This (PR, head SHA) already has an a11y review โ€” a no-op, so
926 # re-polling never double-reviews the same commit.
927 return

How it's verified

The pure pieces (script builder, output parser) are fixture-tested. The orchestration is driven by a FakeSandbox: a canned edit is applied, and each failure mode (non-zero exit, empty output, a raising sandbox) returns False so the caller falls back. The action dispatch is tested both ways โ€” codemod success takes the codemod's edit, no-key takes the fallback's un-export.

The apply path: a canned sandbox edit lands in the checkout.

tests/test_codemod.py ยท 87 lines
tests/test_codemod.py87 lines ยท Python
โ‹ฏ 38 lines hidden (lines 1โ€“38)
1from __future__ import annotations
2 
3import json
4from pathlib import Path
5 
6from froot.adapters.codemod import (
7 apply_export_codemod,
8 build_codemod_script,
9 parse_codemod_edits,
11from froot.domain.sandbox import SandboxResult
12from tests.support import FakeSandbox, make_dead_export
13 
14 
15def test_build_codemod_script_embeds_targets_safely():
16 script = build_codemod_script("src/a b.ts", "weird'name")
17 # The target is a JSON literal inside the heredoc, so quotes/spaces in the
18 # path can't break out of the script.
19 assert '{"file": "src/a b.ts", "symbol": "weird\'name"}' in script
20 assert "npm install ts-morph" in script
21 assert "ts-morph" in script and "getExportedDeclarations" in script
22 
23 
24def test_parse_codemod_edits_reads_path_content_map():
25 out = json.dumps({"src/x.ts": "new text"})
26 assert parse_codemod_edits(out) == {"src/x.ts": "new text"}
27 
28 
29def test_parse_codemod_edits_is_defensive():
30 assert parse_codemod_edits("") == {}
31 assert parse_codemod_edits("not json") == {}
32 assert parse_codemod_edits(json.dumps([1, 2])) == {} # not a dict
33 # Non-string entries are dropped, valid ones kept.
34 assert parse_codemod_edits(json.dumps({"a.ts": "ok", "b.ts": 9})) == {
35 "a.ts": "ok"
36 }
37 
38 
39async def test_apply_export_codemod_writes_the_edits(tmp_path: Path):
40 (tmp_path / "src").mkdir()
41 (tmp_path / "src" / "util.ts").write_text("export const x = 1\n")
42 sandbox = FakeSandbox(
43 SandboxResult(
44 exit_code=0,
45 stdout=json.dumps({"src/util.ts": "const x = 1\n"}),
46 stderr="",
47 )
48 )
49 item = make_dead_export(file="src/util.ts", symbol="x")
50 applied = await apply_export_codemod(tmp_path, item, sandbox=sandbox)
51 assert applied is True
52 assert (tmp_path / "src" / "util.ts").read_text() == "const x = 1\n"
53 # The codemod ran against the checkout the worker handed it.
54 assert sandbox.workdirs == [tmp_path]
55 
โ‹ฏ 32 lines hidden (lines 56โ€“87)
56 
57async def test_apply_export_codemod_false_on_nonzero_exit(tmp_path: Path):
58 sandbox = FakeSandbox(
59 SandboxResult(exit_code=1, stdout="", stderr="ts-morph blew up")
60 )
61 applied = await apply_export_codemod(
62 tmp_path, make_dead_export(), sandbox=sandbox
63 )
64 assert applied is False
65 
66 
67async def test_apply_export_codemod_false_on_empty_result(tmp_path: Path):
68 # The symbol was already gone (codemod emitted {}): nothing applied, so the
69 # caller falls back to the in-worker un-export.
70 sandbox = FakeSandbox(SandboxResult(exit_code=0, stdout="{}", stderr=""))
71 applied = await apply_export_codemod(
72 tmp_path, make_dead_export(), sandbox=sandbox
73 )
74 assert applied is False
75 
76 
77async def test_apply_export_codemod_false_when_sandbox_raises(tmp_path: Path):
78 class _BoomSandbox:
79 async def run(
80 self, workdir: Path, script: str, *, timeout_seconds=None
81 ) -> SandboxResult:
82 raise RuntimeError("FROOT_E2B_API_KEY is unset")
83 
84 applied = await apply_export_codemod(
85 tmp_path, make_dead_export(), sandbox=_BoomSandbox()
86 )
87 assert applied is False

The Node codemod itself is validated by running the exact shipped _CODEMOD_JS against fixtures โ€” a truly-dead symbol is deleted, one used in-file is un-exported, a missing one is a no-op. That is the same posture as deptry: the tool holds the integration truth, not the Python around it. Full suite green: 511 tests, both determinism gates, ruff and mypy.