This document is the complete starting point for tainie. If you are reading
it cold, you have everything you need. There is no other document to consult, no
prior codebase to port, and no hidden context. The design below was discovered
through experiment; this brief presents it as the design to build, with the
reasoning that makes each decision obviously correct rather than incidental.
Read it once top to bottom before writing any code. The "why" sections matter more than the "what" — they exist so that you do not accidentally design around a property that is load-bearing.
tainie does one thing well: named-symbol fan-out edits in well-typed
Python. Given a named symbol (a function, method, or class), it finds every
true reference to that symbol across a workspace using a language server, derives
an exact worklist of edit sites, and applies a small per-site edit to each one —
deterministically, with a type-checker gate guarding every change.
The canonical task it solves: "render_report now accepts a verbose keyword
argument; update every call site to pass verbose=True." That is a
call-site-propagation task. The same machinery covers rename refactors,
inheritance add-method tasks, re-export updates, and type-annotation usage
updates — anything that reduces to "find the real references to one named symbol,
then edit each."
It is not a general-purpose coding agent. It deliberately refuses to guess at scope. That refusal is the source of its reliability.
This section is the heart of the brief. The architecture is not arbitrary; it is the shape that an experiment forced. Understanding the experiment keeps you from "simplifying" the design back into the thing that did not work.
Three approaches to the same family of editing tasks were compared:
A0 — Autonomous grep loop. A model searches freely with grep, reads files, and applies edits in a self-directed loop. Result: 7/10 correct. The turn distribution was bimodal: clean runs finished in ~16 turns, but pathological runs hit the 20-turn cap because the model fixated on decoy files (functions with similar names, string literals, comments) and never converged.
A1 — Autonomous pyrefly-search loop. The same autonomous loop, but now the model
also has LSP workspace/symbol and find_references available as tools it may
call. Intuitively this should help — the model has a precise tool instead of
fuzzy grep. Result: 0/3. Worse than grep. The model called the LSP tools with
wrong arguments, or over-trusted partial output, and produced nothing usable.
A_scout — Decomposed architecture. LSP discovery runs deterministically as a pre-step, outside the model loop, producing an exact worklist. A per-site editor then receives one file plus one instruction and applies a single edit. Result: 10/10, 10–11 turns, deterministic.
The win is the control architecture, not the search tool.
Handing a better search tool to a model inside an autonomous loop (A1) made things worse. Lifting discovery out of the model loop into deterministic precomputation (A_scout) made things work every time. The model is good at the local, bounded act of rewriting one line in one file. It is bad at deciding the scope of a multi-file change while also executing it. So we never ask it to.
A second fixture confirmed the shape. d7 (decoy-dense, edit-light, only three real call sites) ran 5/5 in exactly 3 deterministic turns every time, while A0 managed 4/5 with run-to-run variance.
Classify every sub-task as structural or semantic:
- Structural work is deterministic. It has one correct answer that can be computed: which sites need editing, in what order, and whether a change broke the types. This goes to the LSP and the harness. The model never touches it.
- Semantic work is stochastic. It is the actual rewrite of a line — judgment about how to phrase the edit. This goes to the model, and is gated by a structural check (the type checker) that can veto a bad result.
The model never decides scope. It executes a derived plan. Every time you are tempted to let the model "just figure out which files to touch," remember A1.
Three independent papers validate this decomposition. Cite them in the README.
- RES-Q (arXiv:2406.16801) explicitly decomposes discovery from editing on real GitHub tasks and reports that essentially all variance lives in the edit/prompt layer — exactly our finding.
- SWE-Refactor (arXiv:2602.03712) measures compound multi-site edits failing at 39.4% and identifies atomic per-site editing as the fix, naming cascading-edit failures as the mechanism. This validates per-site decomposition.
- SWEnergy (arXiv:2512.09543) shows small models thrash worst under passive autonomous orchestration and argues that architecturally managing model weaknesses is the thesis. This matches A0's bimodal cap-out distribution.
Two implementation details look like patches at first glance. They are not. They are direct consequences of the problem and must be built in from the first commit. If you remove either one, the system silently breaks.
When you ask a language server for references to a symbol, you can ask it to
include the declaration (the def/class line itself) in the results. We do
not want that.
The worklist is a list of sites to edit. The function's own definition is not a
call site — it is the thing being called. If the definition is in the worklist,
the per-site editor is handed an instruction like "add verbose=True to this
call" pointed at def render_report(...), which is nonsense. A correct editor
refuses to edit a definition as if it were a call, which manifests as a failed
edit and wasted turns.
Why this is the right design, not a workaround: the worklist's semantic contract is "places that reference the symbol from the outside." The declaration is categorically a different kind of entity. Filtering it out is not avoiding a bug — it is honoring the data model. Set
includeDeclaration: Falseat the LSP boundary so the wrong-kind entity never enters the pipeline.
When several sites in the same file need editing, apply them from the highest line number to the lowest — bottom to top.
The reason is mechanical and absolute. Editing happens by locating an expected old string near a known line and replacing it. If you edit top-to-bottom, the first edit can change the line count above every later site (insertions, or multi-line rewrites), so every subsequent site's recorded line number is now stale. The editor looks for its old string at the wrong line and fails — a cascading failure that gets worse with each edit.
Editing bottom-to-top means every edit happens below the sites you have not touched yet. Nothing above shifts, so every remaining site's line number stays valid.
Why this is the right design, not a workaround: line numbers are only stable for content that precedes an edit. Bottom-to-top ordering is the only ordering under which all recorded line numbers remain valid for the duration of a file's edit pass. It is not a defensive hack; it is the correct traversal order for in-place positional editing. Encode it in the worklist itself: sort each file's sites descending by line number when the worklist is built, so the editor cannot apply them in the wrong order.
tainie has a hard, honest boundary. State it as the first substantive
section of README.md, not as an apology.
In scope: Named-symbol fan-out in well-typed Python. The scout finds exact references to a named symbol via LSP reference lookup. This covers call-site propagation, rename refactors, inheritance add-method tasks, re-exports, and type-annotation usages.
Out of scope: Structural subtyping (Protocol implementors without nominal inheritance), dynamic dispatch, runtime-constructed attribute names. These are not "not yet" — they are categorically outside what reference lookup can see.
The structural-typing gap is real and permanent (for now). Protocol
implementors via structural subtyping are invisible to reference lookup. A
class that satisfies a Protocol purely by shape — without writing
class Foo(MyProtocol) — has no recorded edge back to the protocol.
textDocument/implementation returns null for structural Protocol implementors
in every current Python LSP server. This is a property of Python's structural
typing, not a tool-maturity bug we can wait out. So we draw the line there.
The triage data says a narrow tool is the honest tool. Thirty real multi-file commits were sampled. Named-symbol fan-out accounted for 27%; structural and semantic changes for 30%. A tool that handled only fan-out would cover a minority of real-world multi-file edits. The correct response is not to overreach into the 30% we cannot do reliably — it is to do the 27% perfectly and say so.
Turn the limitation into a trust signal. The scout does not silently drop
things it cannot resolve. It reports them on the unresolvable field of the
Worklist. When the scout says "I edited these 11 sites and could not follow
these 2 things," that honesty is the product. A tool that knows the edge of its
own competence is more trustworthy than one that guesses.
The whole-project "done when" condition:
just demoruns, the one claim is demonstrated end-to-end on a real fixture, and a short write-up exists alongside it.
Symbol resolution is the translation layer between an LSP request — "what symbol is at this cursor position?" — and the discovery layer that finds all call sites for a fully-qualified name. It is not part of the scout's worklist derivation. It runs earlier, answering the question: which symbol's references should the scout look up?
Getting this wrong silently corrupts the worklist. The wrong FQN means the wrong references, and the editor rewrites the wrong sites. There is no downstream check that catches this failure — the gate only validates that an edit does not break types; it does not validate that the scout looked up the right symbol. Symbol resolution is load-bearing and must be exact.
A single pass over the cursor's enclosing scope finds the surface token — the name as written at the cursor. That is not sufficient. The implementation needs the fully-qualified name to look up references, and a surface token can only be resolved to an FQN after the module's import graph has been walked. A single pass cannot do both: walking the import graph requires having already identified the symbol's origin, which requires resolving the token, which requires the import graph. The dependency is circular.
Two passes break the circle:
Pass 1 — scope extraction. Collect the names visible at the cursor position: the enclosing function, class, and module scope, plus any names brought in by import statements. This pass does not resolve anything. It records the mapping of surface tokens to their import origins as declared, not as inferred.
Pass 2 — FQN derivation. Walk the cursor's token through the map from Pass 1. Follow the chain: if the token names an imported symbol, the FQN is the import source's module path joined to the symbol name. If it names a locally-defined symbol, the FQN is the current module path joined to the symbol name. Only at this step is the FQN known.
A common import pattern introduces a surface token that does not match the symbol's canonical name:
from reporting.core import render_report as rr
At the cursor, the token is rr. Pass 1 records that rr is bound to
reporting.core.render_report. Pass 2 produces the FQN reporting.core.render_report.
Invariant: Resolution must use the FQN, not the surface token. If the surface token is an alias, the FQN is the aliased target. Passing the alias as the symbol name to the discovery layer returns references to that local binding only — it misses every site that imported the symbol under a different alias or its canonical name.
A surface token can name more than one definition. A local variable, a function parameter, and a module-level import can all bind the same name in overlapping scopes. Choosing the wrong definition produces an FQN that resolves to the wrong symbol, and the scout finds the wrong references.
The correct resolution is scope intersection: collect candidate definitions from all enclosing scopes and select the innermost binding. "Innermost" means: local function scope beats enclosing function scope beats class scope beats module scope. This is Python's standard name resolution order.
Invariant: When multiple scopes bind the same surface token, the resolved FQN is the one from the innermost enclosing scope that contains the cursor. The intersection of "bindings for this token" with "scopes that enclose the cursor position, ordered innermost-first" produces a single unambiguous definition. Anything outside that intersection is a shadow that must be discarded.
The two pitfalls above are framed as invariants rather than implementation steps deliberately. The alias invariant and the shadow invariant each name a property the implementation must satisfy. The two-pass structure is the mechanism; the invariants are the contract. An implementation that satisfies both invariants via a different internal structure is equally correct.
The implementation of symbol resolution is in the TDD phase. Write all test
cases for alias and shadow resolution before reading any reference code. Once
your tests pass, consult the plainpy-lsp-integration spike (resolver.py) as a
reference for patterns and approach — but own the implementation. This ensures you
understand the invariants rather than copying code. Reference code passes against
the d4 and d7 fixtures.
Six packages, each with exactly one responsibility. The dependency graph flows in one direction — there are no cycles.
| Package | Responsibility |
|---|---|
tainie.lsp |
Raw LSP. Start the type-checker server as a subprocess, send/receive JSON-RPC. No SDK, no abstraction over the wire protocol. |
tainie.scout |
Discovery. Symbol name + workspace path → sorted Worklist with unresolvable flag. |
tainie.editor |
Per-site edit. One EditSite → model call → apply → return EditResult. |
tainie.gate |
Structural check. Type-check one file, diff diagnostics against a baseline. |
tainie.harness |
Fixtures, run records, oracle runner. |
tainie.authoring |
Cooperative authoring contract: ruff config + validator. |
Dependency direction (one way only):
lsp
/ | \
scout gate (editor uses gate, which uses lsp)
\ | /
harness (depends on all)
scoutdepends onlsp.gatedepends onlsp.editordepends onlspand ongate.harnessdepends on everything.authoringis standalone (config + validator).
No package may import a package that (transitively) imports it.
The experiment's whole point is that mixing scope-decision with editing breaks.
The package boundaries make that mixing structurally impossible: scout has no
way to call the model, and editor has no way to expand its own scope, because
neither imports the machinery that would let it. The architecture enforces the
discipline; you cannot accidentally drift back to an autonomous loop.
Every cross-package dependency is registered by type and fetched by type
through an svcs container. A package asks for what it needs:
from svcs import Container
# Registration (composition root, e.g. container.py or a demo entrypoint):
container.register_value(LspClient, LspClient(...))
# Consumption (anywhere downstream):
client = container.get(LspClient)container.get(LspClient) raises at call time if LspClient was never
registered.
Enforcement at the boundary, not at definition. A package declares its needs
by asking the container for a type. If the wiring is wrong, the failure happens
exactly where the dependency is used, with the missing type named in the error —
not as a vague import error three layers away. The composition root (the demo, or
a test fixture) is the only place that knows concrete construction. This keeps
the backend swappable: registering a different LspClient implementation
is a one-line change at the root, and nothing
downstream notices. See Section 11 — the swap must be a registration change, not
an architecture change, and type-keyed DI is what makes that true.
These are the carriers that move between phases. The snippets show the intended shape; build them as the right design for the data, not as code to transcribe.
from dataclasses import dataclass
from typing import Union
from pydantic import BaseModel
@dataclass(frozen=True)
class EditSite:
"""One reference location that needs editing."""
file: str # relative path from workspace root
symbol: str # the symbol name that was looked up
line: int # 0-indexed line number of the reference
what_to_change: str # human-readable edit instruction
@dataclass(frozen=True)
class Worklist:
"""Ordered edit sites, ready for bottom-to-top application."""
sites: tuple[EditSite, ...]
unresolvable: tuple[str, ...] # things the scout could not follow
class SiteEdit(BaseModel):
"""Model output: per-site edit (pydantic-enforced schema)."""
new_text: str # the new text to replace with
rationale: str # why this edit is correct (max_length=200)
@dataclass
class AppliedResult:
"""Edit succeeded and passed gate."""
site: EditSite
change_summary: str
@dataclass
class SkippedResult:
"""Edit skipped: unresolvable or gate failed after retries."""
site: EditSite
reason: str
EditResult = Union[AppliedResult, SkippedResult]
@dataclass
class GateResult:
passed: bool
diagnostics: list[str]EditSite and Worklist are produced by the scout and consumed by the editor.
Between those phases nothing should mutate them — a silently changed line would
reintroduce exactly the cascading-edit failure that bottom-to-top ordering
exists to prevent. Freezing them makes accidental mutation a hard error rather
than a debugging session. EditResult and GateResult are outputs that may be
assembled incrementally, so they stay mutable.
The thing the scout could not follow is part of the discovery result, not an exception. Carrying it on the worklist forces every consumer to confront it: you cannot read the sites without also seeing what was skipped. That is the trust signal from Section 4, made structural.
The worklist's contract includes ordering (descending by line within each file).
A tuple signals "this sequence is final and meaningful," reinforcing that the
editor must not re-sort or re-order — it simply iterates.
Keep this table in front of you. Anything on the left must never involve the model. Anything on the right must be gated by something on the left.
- Worklist derivation:
workspace/symbol→textDocument/references(includeDeclaration=False)→ filter import lines → group by file → sort descending by line. - Import-line filter: drop any reference whose line starts with
fromorimport. (Import statements reference the symbol but are not edit sites for a call-propagation task.) - Bottom-to-top ordering: within each file, order highest line number first (Section 3.2).
- Structural gate: run the type checker on the modified file; diff its diagnostics against the pre-edit baseline. A new diagnostic is a revert signal.
- Per-site editor: a pydantic-ai micro-agent that takes one file (rendered with line
numbers), one symbol, one line, and one instruction. The agent calls the model with
output_type=SiteEdit(new_text, rationale), then applies the replacement. If the gate fails, pydantic-ai'sModelRetryreprompts with diagnostics as context (2–3 attempts). Terminal states:Applied(gate passed) orSkipped(all retries exhausted).
The fixture files, and real code, may already have type diagnostics unrelated to our edit. Demanding a clean check would reject correct edits in already-imperfect code. What we actually care about is "did my edit make things worse?" — so we capture the diagnostics before the edit and only treat new ones as failures. This makes the gate precise about causation rather than a blunt cleanliness check.
tainie targets new, well-typed Python written to maximize structural
visibility. The scout can only see what the type system records. So the project
ships an authoring contract: a set of ruff-enforceable rules that keep the
call-graph fully visible to reference lookup.
The ruff config is the contract. A [tool.ruff.lint] section selecting
these rules, each annotated with why it is load-bearing for structural
visibility, is the authoring-contract artifact. It lives in tainie.authoring
alongside a validator.
| Rule / practice | Why it is load-bearing |
|---|---|
ANN401 — no bare Any |
Any erases edges. A parameter typed Any lets references to its members vanish from the call graph. |
| Full return-type annotations always | Inferred returns let call-graph edges go dark; the LSP cannot always follow an un-annotated return through to its callers. |
ParamSpec + Concatenate on every decorator |
Preserves the wrapped callee's signature through the wrapper, so references to the original parameters survive decoration. |
@overload instead of union-typed polymorphic functions |
Each overload is a distinct, resolvable signature; a union-typed function blurs which call shape a reference belongs to. |
TypedDict / Pydantic models instead of dict literals |
Structured types give named, resolvable fields; bare dict access is invisible to reference lookup. |
Explicit Protocol + nominal inheritance (class Foo(MyProtocol) even when structurally unnecessary) |
Makes implementors discoverable via reference lookup. Pure structural conformance is invisible (Section 4). |
No from x import * |
One name, one definition. Star imports create ambiguous, hard-to-resolve symbol origins. |
Keyword-only arguments (*,) on edited functions |
Name-anchored. The model cannot get positional order wrong when the argument must be named. |
No setattr, __getattr__, runtime-constructed attribute names |
Attributes that do not exist statically cannot be referenced statically; they are invisible to the scout. |
The whole project rests on the type system seeing the truth. If authored code
hides edges (with Any, star imports, dynamic attributes), the scout's
worklist is silently incomplete and the reliability guarantee evaporates. Making
the rules a checked config means a contributor cannot introduce invisibility
without ruff complaining. The contract is enforced, not aspirational.
tainie is built rung by rung with Superpowers SDD. Every rung follows the
same sequence, with no exceptions.
- Write the spec as the roadmap row: contract, inputs, outputs, failure modes.
- Write a failing test and commit it. The test file must exist in git history before the implementation file.
- Implement until the failing test passes.
- Run
just quality— ruff check, ruff format --check, pyrefly check, pytest. - Close the rung.
No implementation may be written without a prior failing test. No rung may be closed unless the test file predates the implementation file in git history.
If every implementation file were deleted, re-running the roadmap should regenerate a working system. This is the test of whether the design lives in the right place:
- The roadmap is the source of truth.
- The specs encode every design decision.
- The tests are derived from the specs.
- The implementation is derived from the failing tests.
If a design decision lives only in the implementation, it is in the wrong place. Push it up into the spec so the system is reconstructible from the roadmap alone.
Phase 1–5 (core mission): Use pyrefly via CLI, not LSP subprocess.
- Discovery:
pyrefly check --report-gleanyieldsXRefsViaNameByTargetfacts (proven in research) - Gate:
pyrefly check <file>yields type diagnostics (proven in research) - Benefit: Simpler than subprocess JSON-RPC management; proven to work at d4/d7 scale
Future refactoring spikes (RENAME-02+): Pyrefly LSP's code-action surface (~18 refactoring operations) becomes available as an optional backend for coordinated multi-site refactors (rename, extract, etc). Rope was a workaround; LSP code actions are the real option. Register via svcs.
The Discovery interface (and future Refactorer interface) must be backend-agnostic.
The implementation knows which backend (Glean, LSP, etc) is in use, but callers don't.
Swapping backends is a registration change at the svcs composition root (Section 6), not an
architecture change.
Unresolved (scope boundary question, not Phase 1 blocker):
Pyrefly LSP's textDocument/implementation returns null for structural Protocol implementors
in prior testing. If this is a permanent property of Python's structural typing (Section 4),
the scope boundary stands. If future testing proves it works, that is a scope-expansion decision
made separately, not a Phase 1 architecture dependency.
pyproject.tomlfor thetainiepackage. Dependencies:pydantic,svcs,openai.justfilewithquality,test, anddemorecipes.tainie/container.py: an svcs container with register/get, plus a smoke test.- Ruff config with the authoring rules (Section 9) selected, each with a comment explaining why it is load-bearing.
- Done when:
just qualitypasses on the (otherwise empty) project.
tainie/discovery/glean.py: implementDiscoveryprotocol backed by Glean CLI facts.- Run
pyrefly check --report-glean <files>to get structured discovery facts - Parse
python.XRefsViaNameByTargetto find all references to a symbol - Parse
python.DeclarationLocationto find definition locations - Return symbol definitions and call-site locations
- Run
- Design:
Discoveryis an svcs-registered protocol. Currently backed by Glean CLI; can be swapped for LSP or other backends later without changing callers (Section 11). - Benchmark: Measure
pyrefly check --report-gleanwall-clock latency on realistic Python workspaces (100+ files) to confirm sub-second performance claimed in Section 11. - Done when: symbol discovery returns the exact 11 call sites in d4 fixture and correctly identifies definition locations for multi-level FQN resolution.
tainie/scout/discover.py: symbol name + workspace path →Worklist.- Must implement, in order:
includeDeclaration=False, import-line filter, group-by-file, sort-descending-by-line,unresolvableflag. - Add the d4 and d7 fixture directories (Section 13) as the canonical test corpus. The files themselves are the test data.
- Done when:
scout(workspace=d4_path, symbol="render_report")produces aWorklistwhose sites are exactly the 11 true call sites acrossviews.py(×3),handlers.py(×3), andservices.py(×5) — and thecore.pydefinition is not present — andscout(workspace=d7_path, symbol="format_output")produces exactly[report.py, dashboard.py, export.py].
tainie/gate/check.py: run the type checker on one file, return aGateResultwith the diagnostic list.tainie/gate/diff.py: compare before/after diagnostic sets; a new diagnostic is a revert signal.- Done when: deliberately corrupting a file in the d4 workspace produces a
GateResultwithpassed=False.
tainie/editor/schema.py:SiteEdit(new_text, rationale)pydantic model.tainie/editor/apply.py: oneEditSite+ file content → pydantic-ai micro-agent withoutput_type=SiteEdit→ apply replacement → gate check → ModelRetry loop (2–3 attempts on gate failure) →AppliedResult | SkippedResult.- The context handed to the model is exactly: the file rendered with line numbers, the symbol name, the line number, and the instruction. On retry, include gate diagnostics. Nothing else. Minimal context is the point — the editor's whole job is one bounded local rewrite per site.
- Done when: a single-site smoke test passes against a real local model with 100% schema compliance (spike-validated with Qwen3.5-9B).
just demoruns the scout → editor loop on the d4 fixture: print the worklist, apply all edits (bottom-to-top per file), run the oracle gate, print the result.- Done when: a deterministic 10/10 result matches the research numbers, with the A0 baseline (7/10) noted alongside for contrast.
These two fixtures are the project's ground truth. Reproduce them exactly under
the harness. Each fixture directory has a task.md, a work/ tree (the code to
edit), and an oracle/ test file that defines correctness.
d4/task.md
`render_report` already accepts a `verbose: bool = False` parameter. Update every call site to pass `verbose=True`.
Find all places in the codebase that call `render_report(...)` and add `verbose=True` to each call.
**Constraints:**
- Do NOT modify `render_report_legacy`, `render_report_cached`, or any other `render_report_*` variants — those are unrelated functions.
- Only update true call sites of `render_report` itself.
d4/work/core.py (the function — do not edit this file)
def render_report(context: dict, verbose: bool = False) -> str:
prefix = "[verbose]" if verbose else "[brief]"
return f"{prefix} Report: {context}"
def render_report_legacy(context: dict) -> str: # DECOY
return f"Legacy: {context}"
def render_report_cached(context: dict) -> str: # DECOY
return f"Cached: {context}"d4/work/views.py (3 true call sites)
from core import render_report
def dashboard(): return render_report({"page": "dashboard"})
def profile(): return render_report({"page": "profile"})
def settings(): return render_report({"page": "settings"})d4/work/handlers.py (3 true call sites, 1 decoy using render_report_cached)
from core import render_report, render_report_cached
def handle_get(): return render_report({"method": "GET"})
def handle_post(): return render_report({"method": "POST"})
def handle_put(): return render_report_cached({"method": "PUT"}) # DECOY: different function
def handle_delete(): return render_report({"method": "DELETE"})d4/work/services.py (5 true call sites)
from core import render_report
class ReportService:
def generate(self, data): return render_report(data)
def export(self, data): return render_report(data)
class AnalyticsService:
def track(self, event): return render_report(event)
def create_report(params): return render_report(params)
def sync_reports(batch): return render_report(batch)d4/work/utils.py (all decoys — grep finds these, LSP does not)
from core import render_report_legacy, render_report_cached
def render_report_html(data): ... # DECOY: different function name
def render_report_json(data): ... # DECOY: different function name
USAGE = "render_report({'key': 'value'})" # DECOY: string literal
# render_report is the main function # DECOY: commentd4/oracle/test_d4.py (what must pass)
# Every true call site must return a string starting with "[verbose]"
def test_views_dashboard_verbose():
assert dashboard().startswith("[verbose]")
def test_views_profile_verbose():
assert profile().startswith("[verbose]")
def test_views_settings_verbose():
assert settings().startswith("[verbose]")
def test_handlers_get_verbose():
assert handle_get().startswith("[verbose]")
def test_handlers_post_verbose():
assert handle_post().startswith("[verbose]")
def test_handlers_delete_verbose():
assert handle_delete().startswith("[verbose]")
def test_services_generate_verbose():
assert ReportService().generate({}).startswith("[verbose]")
def test_services_export_verbose():
assert ReportService().export({}).startswith("[verbose]")
def test_services_track_verbose():
assert AnalyticsService().track({}).startswith("[verbose]")
def test_create_report_verbose():
assert create_report({}).startswith("[verbose]")
def test_sync_reports_verbose():
assert sync_reports({}).startswith("[verbose]")Why d4 is the canonical case. A grep for render_report returns 15+ matches:
the real definition, the two decoy variants render_report_legacy and
render_report_cached, the differently-named render_report_html /
render_report_json, a string literal, and a comment. An autonomous grep loop
(A0) fixates on the decoy-heavy utils.py on some seeds and gets 7/10.
workspace/symbol("render_report") followed by
textDocument/references(includeDeclaration=False) returns exactly the 11 true
call sites and nothing else. The scout gets 10/10 deterministically. d4 is the
single clearest demonstration that the win is the architecture.
d7/task.md
Update every call site of `format_output` to pass `truncate=True`.
The function `format_output` in `formatter.py` now accepts a `truncate` parameter (default: `False`). Find all call sites and update them to pass `truncate=True`.
Constraints: Only update actual call sites (not comments, string literals, type aliases, or method names). Do not modify the function definition itself.
Why grep cannot solve this: Grep for `format_output` finds the true call sites, the definition, the legacy function `format_output_legacy`, the method `OutputFormatter.format_output` (different symbol), a TODO comment, string constants in types.py, a type alias `FormatOutput`, and import statements. Only a type-aware tool can distinguish the actual call sites.
d7 shape. Only 3 true call sites — in report.py, dashboard.py, and
export.py. Surrounding them are 6+ decoy categories: the definition, the legacy
function format_output_legacy, the unrelated method OutputFormatter.format_output,
a TODO comment, string constants in types.py, the type alias FormatOutput, and
import statements. Reproduce these decoys in the work/ tree so the fixture
exercises the import-line filter and the symbol disambiguation. The oracle checks
that each of the three true call sites now passes truncate=True.
Why d7 matters. It is the edit-light stress test. With only three real sites buried in dense decoys, the scout runs 5/5 in exactly 3 deterministic turns every time, while A0 manages 4/5 with variance. d7 proves the property is not about edit volume — it is about deterministic discovery cutting through noise.
Phase 0 produces these files. They encode the workspace conventions and must all exist before any package code is written. Every file below is the complete expected contents — not a template to adapt but a specification to implement.
tainie/
├── .github/
│ ├── actions/setup-python-uv/action.yml
│ └── workflows/
│ ├── ci.yml
│ └── pypi.yml
├── src/
│ └── tainie/
│ ├── __init__.py
│ └── container.py
├── tests/
│ └── test_container.py
├── .gitignore
├── AGENTS.md
├── CLAUDE.md
├── Justfile
├── README.md
├── conftest.py
└── pyproject.toml
[project]
name = "tainie"
version = "0.1.0"
description = "Deterministic LSP-backed discovery for named-symbol fan-out edits in typed Python"
requires-python = ">=3.12"
dependencies = [
"openai>=1",
"pydantic>=2",
"svcs>=24",
]
[build-system]
requires = ["uv_build>=0.8.17,<2"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.1.0",
"ruff>=0.15.8",
"pyrefly>=1.0.0",
]
[tool.pytest.ini_options]
testpaths = ["tests", "src"]
addopts = "-p no:doctest -m \"not slow\""
asyncio_mode = "auto"
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
]
timeout = 60
faulthandler_timeout = 120
[tool.ruff.lint]
select = [
# Authoring-contract rules (load-bearing for LSP structural visibility):
"ANN401", # no bare Any — Any is where LSP call-graph recall ends
"F401", # no unused imports
"F811", # no redefinition of unused name — one name, one definition
# Standard quality:
"E9", # syntax errors
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"N", # pep8 naming
]
ignore = []
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["ANN"] # tests don't need full annotations"""Root pytest configuration."""tainie is standalone — it defines recipes directly rather than importing
from a workspace shared.just.
# Default: list recipes
default:
@just --list
# Install all dependencies
install:
uv sync
# Lint
lint *ARGS:
uv run ruff check {{ ARGS }} .
# Fix lint issues
lint-fix:
uv run ruff check --fix .
# Format
fmt *ARGS:
uv run ruff format {{ ARGS }} .
# Check formatting
fmt-check *ARGS:
uv run ruff format --check {{ ARGS }} .
# Type check
typecheck *ARGS:
uv run pyrefly check {{ ARGS }}
# All quality checks (lint + format + types)
quality: lint fmt-check typecheck
# Fix lint + format
quality-fix: lint-fix fmt
# Run tests
test *ARGS:
uv run pytest {{ ARGS }}
# Run tests with coverage
test-cov *ARGS:
uv run pytest --cov=tainie {{ ARGS }}
# All CI checks
ci-checks: quality test-cov
# Run the demo: scout + editor on d4 fixture
demo:
uv run python -m tainie.demo# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# OS-specific
.DS_Store
# Sphinx build
docs/_build/
# Coverage
.coverage
htmlcov/
# AI assistant local settings (not committed)
.claude
.mcp.json
.junie
.aiassistant# tainie
Deterministic LSP-backed discovery for named-symbol fan-out edits in typed Python.
Phase 1: discovery only — the scout produces a structured worklist; no editor yet.
## Depends on
- `svcs` — service registry for explicit dependency contracts
- `openai` — OpenAI-compatible API client (used with local Mellum2 via oMLX)
- `pydantic` — data validation and serialization
## Tools
- **uv** — package and virtualenv manager. Never use pip.
- **ruff** — linter and formatter. Never use black or isort separately.
- **pyrefly** — type checker.
## Behavioral notes
- `includeDeclaration=False` in every `textDocument/references` request — this is
not a workaround, it is the correct design. The definition is not an edit site.
- Edits within a file are always applied bottom-to-top (highest line number first)
— this prevents earlier edits from shifting the line numbers of later ones.
- The `unresolvable` field on Worklist is a first-class output, not an error state.# tainie
Deterministic LSP-backed discovery for named-symbol fan-out edits in typed Python.
Phase 1: discovery only — the scout produces a structured worklist; no editor yet.
## Depends on
- `svcs` — service registry for explicit dependency contracts
- `openai` — OpenAI-compatible API client (used with local Mellum2 via oMLX)
- `pydantic` — data validation and serialization
## Tools
- **uv** — package and virtualenv manager. Never use pip.
- **ruff** — linter and formatter. Never use black or isort separately.
- **pyrefly** — type checker.
## Behavioral notes
- `includeDeclaration=False` in every `textDocument/references` request — this is
not a workaround, it is the correct design. The definition is not an edit site.
- Edits within a file are always applied bottom-to-top (highest line number first)
— this prevents earlier edits from shifting the line numbers of later ones.
- The `unresolvable` field on Worklist is a first-class output, not an error state.name: Setup Python and uv
description: Install uv, sync dependencies, setup just
runs:
using: composite
steps:
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Sync dependencies
shell: bash
run: uv sync --frozen
- name: Install just
uses: extractions/setup-just@v2name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-python-uv
- run: just ci-checksjust quality passes on an empty project: ruff finds no issues, pyrefly finds no
type errors, and just test exits 0 with the container smoke test green.
tainie does named-symbol fan-out edits in well-typed Python. A deterministic
scout turns a symbol into an exact, line-descending worklist via LSP
reference lookup (includeDeclaration=False, import lines filtered), reporting
anything it could not resolve. A per-site editor then makes one bounded local
edit per site — bottom-to-top within each file — with a type-checker gate
vetoing any change that introduces a new diagnostic. The model never decides
scope; it only rewrites one line at a time. This decomposition turned a 7/10
autonomous grep loop into a deterministic 10/10, and that result — not any
individual tool — is the design.