@demlik/structure-sweep
v0.4.2
Published
Ask Jev which feature and which role every source file in a folder belongs to, judge code-graph collapse pairs, and move files into feature folders with their imports rewritten.
Readme
@demlik/structure-sweep
Sort a codebase onto its own feature vocabulary. @demlik/code-graph proves
structure; structure-sweep asks TypeSafe Jev what the code means, then moves it.
propose— gather repo signals and write a prompt for drafting the feature vocabularysweepreads, with you or your coding agent doing the drafting.sweep— for every source file under a folder, which feature it belongs to, which role it plays, and whether a business rule sits inside an API file.pairs— for every code-graph collapse pair, whether the two functions are the same decision: one request per anchor function, one yes/no per candidate partner, by default, or the three-way same decision / look-alike / shared helper verdict per pair under--pairwise.move plan | apply— move files into feature folders where Jev's verdict and the import graph agree, committing the renames apart from the import rewrites and never moving an entry file.groups list | show— read the lowering pipeline's rule groups, owners and collapse specs back as JSON, with the evidence behind each group.
npm install -D @demlik/structure-sweepJev credentials
Calls go to Jev's endpoint through @demlik/tea/jev,
which decodes each reply; @demlik/tea/retry-backoff retries 429/529 and dropped connections. The
key is read from TYPESAFE_API_KEY — sweep and pairs refuse to start without it. propose,
move, groups and pairs --plan never call Jev.
The vocabulary file
sweep and move plan read the features and roles from structure-sweep.config.json at the
repository root (--config <file> to point elsewhere). It is parsed with zod when the command
starts; an invalid file stops the run with every problem listed, before any call is made. To
draft one for a repository you do not know yet, start with
propose.
{
"product": "an online bookshop",
"features": {
"catalog": "Books, authors, editions and search over them.",
"checkout": "Carts, payment, discounts and order placement.",
"accounts": "Sign-up, login, sessions and a customer's profile."
},
"roles": {
"business_rule": {
"description": "Decides what is allowed or what outcome happens: eligibility, limits, pricing, status transitions.",
"dir": "rules"
},
"api_surface": {
"description": "Exposes an endpoint: parses input, calls other code, shapes output.",
"dir": "api"
},
"flows": {
"description": "Orchestrates a multi-step user or system flow: a saga, a wizard, a workflow, or the sequencing of steps across other code.",
"dir": "flows"
},
"persistence": {
"description": "Reads or writes storage: queries, repositories, caches.",
"dir": "store"
},
"plumbing": {
"description": "Generic helper with no product concept.",
"dir": "lib",
"shared": true
}
}
}| Field | Meaning |
|---|---|
| product | Optional. One line on what the codebase is; Jev reads each file against it. |
| features | lower_snake_case key → what belongs there. At least two. A feature's folder is its key in kebab-case (order_history → order-history/). |
| roles | lower_snake_case key → description, the folder dir a file with that role lands in, and shared (default false): true puts the folder at <scope>/src/<dir> instead of under the feature. At least two. |
Answers are cached by the file's content hash and a fingerprint of this file, so rewording a description re-asks every file, and an unchanged file under an unchanged vocabulary is never asked twice.
Commands
Folders and paths are taken relative to where you run the command; outputs default to
.structure-sweep/ at the repository root.
--graph on sweep and propose reads saved code-graph JSON. Generate it with
code-graph <path> --graph --out graph.json, adding analysis flags for the data you need.
Choose a path containing all relevant files. See code-graph's
commands and scope.
structure-sweep propose <folder>...
structure-sweep propose apps/web/src services/api/src --graph web-graph.jsonWrites the material for drafting a vocabulary, and leaves the drafting to whoever runs it: you, or
the coding agent you are working in (Claude Code, Codex, Cursor or any other). It calls no model and
no network, reads no API key, and reads the git tree at --ref, not the working copy.
It writes two files:
.structure-sweep/signals.json— the folders under each folder you pass (to--depthlevels, each with the number of filessweepwould classify), every named workspacepackage.json, and, from each--graphfile, the code-graph clusters that span several folders (the graph needscode-graph --graph --clusters; each cluster names the file it came from, since two graphs number theirs independently) and every cross-runtimeservice.methodcall. Beside those, two content signals read from the files themselves (see below). The same checkout gives byte-identical JSON..structure-sweep/propose-prompt.md— a prompt that carries those signals, the roles to use, the vocabulary's JSON Schema, a filled example, the number of features to draft, and the path to write the config to. It names no coding agent, so any of them can follow it.
Either file already there stops the run; --force overwrites both.
The content signals come from what each file sweep would classify says, not from where it sits:
- terms — the exported names split into words (
parseHTTPReply→parse,http,reply; camelCase, PascalCase and snake_case alike), with the number of files exporting a name that holds each word. The 60 most widespread are listed. - import clusters — groups of files more tightly linked by relative imports to each other than to the rest, found by label propagation over the import graph. A file imported by more than 8 others is shared plumbing and links none of its importers. Each group is given as its member files (up to 12, with its full size) and its 10 most common terms; the 30 largest groups are listed.
Every list is capped, so the prompt does not grow with the file count.
--blind drafts from the code alone. It leaves the folders, the packages and the code-graph
clusters (which code-graph gives only as folders) out of both files, and names every file in the
content signals by an opaque id — f<n> plus the extension, numbered in code-unit order of path, so
one checkout always gets the same ids. Cross-runtime service.method calls stay. The prompt says the
names were withheld on purpose, and its check step runs sweep --redact so the loop stays blind.
Use it to measure propose against a repository whose folders are the answer, or on a repository
whose folder names mislead.
The loop, by hand or by an agent reading the prompt:
- propose —
structure-sweep propose <folder>.... - draft — read
propose-prompt.mdand write the config to.structure-sweep/proposed.config.json. - sweep —
structure-sweep sweep <folder>... --config .structure-sweep/proposed.config.json. - score —
structure-sweep score. - refine —
structure-sweep propose refine, below, turns the adjusting into a loop an agent can run until the score plateaus.
With no --config, the prompt hands over five built-in roles: business_rule (rules/),
api_surface (api/), flows (flows/: code that orchestrates a multi-step user or system flow —
sagas, wizards, workflows, step sequencing), persistence (store/) and plumbing (lib/,
shared) — the ones in the example above. With --config, it takes that vocabulary's roles and product line instead.
| Flag | Default | |
|---|---|---|
| --features <n> | 12 | features the prompt asks for (at least 2) |
| --config <file> | five built-in roles | vocabulary to take the roles and product line from |
| --graph <file> | none | code-graph --graph JSON to read clusters and cross-runtime calls from (repeatable) |
| --blind | off | leave out folders, packages and code-graph clusters, and name files by opaque id |
| --ref <ref> | HEAD | git tree to read folders, packages and file content from |
| --depth <n> | 3 | folder levels listed under each folder (unused with --blind) |
| --draft <file> | .structure-sweep/proposed.config.json | where the prompt says to write the config |
| --out <file> | .structure-sweep/signals.json | signals JSON |
| --prompt <file> | .structure-sweep/propose-prompt.md | prompt |
| --force | off | overwrite --out and --prompt |
structure-sweep propose refine
structure-sweep propose refine --config .structure-sweep/proposed.config.jsonOne step of the refine loop over a drafted vocabulary. Like propose, it calls no model and no
network and reads no API key: it diagnoses, and the editing is left to you or your coding agent.
sweep is the only paid step in the loop, and refine never runs it: it writes the files to sweep
and reads the sweep back.
Each call:
- scores the assignment with the same scorer as
score: the--verdictsrows, with every--sweeprow laid over the row for its path. A swept row brings its judgement and keeps the verdict row'sscope, sincesweep --filesrecords a file's parent folder and the score only pairs files within one scope. A row whose feature is no longer in the vocabulary is orphaned and not scored; a row judged under another vocabulary fingerprint is stale, scored, and flagged. - writes the report (
refine.json): overall F1 and confident share, per-feature cohesion (F1, precision, recall, files and confident share for every feature in the vocabulary), the stale and orphaned rows, and:- merge candidates — feature pairs ranked by coupling: of the co-changed file pairs touching either feature, the share with one file in each. 1 means the two only ever change together.
- split candidates — features of at least 4 files ranked by cohesion, lowest first. A
kept change set is a commit cut to its labelled files, kept when it holds 2..
--max-filesof them. Cohesion takes the feature's files that at least one kept change set touched, and of the same-scope pairs among them gives the share some kept change set changed together. A file of the feature that no kept change set touched is left out of its cohesion: no history is no evidence either way. It still counts toward the feature'sfilesand the at-least-4-files threshold, so a feature can clear the threshold with its cohesion read from fewer files. Cohesion is the feature'sprecisionin the per-feature cohesion entries (featuresinrefine.json): the split number and that precision are the same value. - with
--sweep, Jev signals from the swept rows judged under this vocabulary: the confident share per feature, and the top-2 confusion pairs, the features that were a row's two most probable answers, counted over rows. Without it, the report'ssignals.basisisco-changeand the prompt says the diagnostics are co-change only. - plateau status.
- records the run in the history file: one entry per vocabulary fingerprint with its overall
F1. A call under the same fingerprint as the last entry replaces it, so re-running over
unchanged inputs changes nothing. The loop has plateaued when
|ΔF1|against the run before stayed under--thresholdfor--patienceconsecutive runs. - writes the sample list (
refine-sample.txt), repo-relative paths one per line: every orphaned file, then for each feature a slice of⌊--sample / features⌋files (at least one), stale ones before current ones, each group in the order of a hash of the path. Only pathssweep --filesaccepts at--refare listed. - writes the prompt (
refine-prompt.md): the score and its delta, the top five merge and split candidates and Jev confusion pairs with their evidence, the stale and orphaned files, and either the next edit-and-resweep step or an explicit stop.
The same vocabulary, verdicts, sweep file, history, git history and flags give byte-identical report, prompt and sample list.
The loop, run by an agent reading the prompt:
- refine —
structure-sweep propose refine. - edit — change
.structure-sweep/proposed.config.jsonasrefine-prompt.mdsays. - sweep the sample —
structure-sweep sweep --files .structure-sweep/refine-sample.txt --config .structure-sweep/proposed.config.json --out .structure-sweep/refine-sweep.json. - refine with the sweep —
structure-sweep propose refine --sweep .structure-sweep/refine-sweep.json, then back to 2, until the prompt says stop.
| Flag | Default | |
|---|---|---|
| --config <file> | .structure-sweep/proposed.config.json | vocabulary being refined |
| --verdicts <file> | .structure-sweep/verdicts.json | sweep output to score |
| --sweep <file> | none | sweep over the sample list; re-judges those rows and adds Jev's signals |
| --ref <ref> | HEAD | read history back from here, and list the sample from the files tracked here |
| --since <date> | none | only commits after this date |
| --pr-only | off | only commits whose subject ends in (#N) |
| --max-files <n> | 40 | drop commits touching more labelled files than this |
| --threshold <x> | 0.005 | \|ΔF1\| under this counts toward a plateau |
| --patience <n> | 2 | consecutive runs under the threshold that plateau |
| --sample <n> | 64 | files spread over the features in the sample list, on top of the orphaned ones |
| --out <file> | .structure-sweep/refine.json | report JSON |
| --prompt <file> | .structure-sweep/refine-prompt.md | prompt |
| --history <file> | .structure-sweep/refine-history.json | one entry per vocabulary, read and rewritten |
| --sample-list <file> | .structure-sweep/refine-sample.txt | paths to sweep next |
structure-sweep sweep <folder>...
structure-sweep sweep services/api/src apps/web/srcReads each folder's .ts/.tsx sources (tests, stories and .d.ts excluded) from the git tree
--ref (default HEAD) and writes .structure-sweep/verdicts.json, one row per file. A folder
that was never swept is simply all misses: the verdict file is created if absent and a new folder's
rows are added beside the others. --graph <file> (repeatable) passes a code-graph --graph JSON
as extra evidence. A file whose call fails is left out and asked again on the next run; the command
then exits 1. . sweeps the whole tree, root-level sources included, and its rows record
"scope": ".". A folder outside the repository is refused.
--files <path> judges a chosen set of files instead of whole folders — a stratified sample, say:
structure-sweep sweep --files sample.txt
printf 'apps/web/src/cart.ts\nservices/api/src/billing/invoice.ts\n' | structure-sweep sweep --files -<path> holds one repo-relative source path per line, resolved from the current directory like
--graph; - reads the list from stdin. Blank lines are skipped and a repeated path is judged
once. Every listed path is checked against the files git tracks at --ref before Jev is asked
anything: a path git does not track there, one the sweep would never judge (not .ts/.tsx, or a
test, story or .d.ts), or one at the repository root fails the run with an error naming every
such path. --files cannot be combined with folders; pass one or the other.
Each listed file is judged over its parent folder's evidence: the run reads every source a sweep of
that folder would read, gathers the evidence over all of them, and asks only about the listed ones.
So a sampled file gets exactly the state structure-sweep sweep <its parent folder> would give it,
whichever other files are listed, and its row records that folder as scope. Rows are cached like
a folder run's, and --redact applies unchanged.
--redact (off by default) keeps names out of what Jev sees, so it has to place a file by its
code rather than read the answer off its folder. The file's path becomes an opaque id that keeps
only the extension (f3.tsx); every relative specifier — in import … from, a bare import "…",
export … from, export * from, a dynamic import("…") and require("…"), whether in the
import list or left in the source — becomes the same kind of id (./f3); the files that import
it are listed by their ids; and the --graph caller and callee file names become g<n> ids that
keep their ×n counts. The ids are fixed by the input, so the same files give the same payload.
A path alias (a tsconfig paths entry such as @app/billing) is resolved the way the folder's own
build would resolve it, and becomes an id too. An alias that resolves to a file at --ref gets that
file's id, the same one a relative import of it gets. An alias with no such answer (it resolves to
nothing, or to a file that is ignored, untracked or not at --ref) gets an id of its own, so its
text is still hidden. Only a specifier that resolves into node_modules or to a Node builtin is
kept as written: package specifiers (zod, @scope/pkg), and an alias that points into
node_modules. Exported names, comments and string literals are kept too. verdicts.json still records each file's real path. A redacted row
is marked "redacted": true, and redacted and unredacted runs never share cached answers: each
asks Jev again about a file the other answered.
--nominated (off by default) asks Jev only about the files the import graph says may be
misplaced, instead of every file:
- Current feature. A file's current feature is the vocabulary feature whose folder name
(the key with
_turned into-, asmove plannames a feature's home) matches a directory segment of its path. The deepest matching segment wins; with no match the file has no current feature. The file's own name never counts. - Nomination. The run reads the import graph the way
move plandoes (resolved relative imports and re-exports between tracked sources, through the nearesttsconfig.json) and takes each file's pull: the current feature holding a strict majority of its import edges, both directions, to files that have one. A file is asked when its pull and its current feature disagree: they name different features, or only one of them names a feature. A file whose pull matches its folder, or that has neither, is skipped. A folder run reads that folder's graph; under--filesthe graph spans the nearest folder at or above each listed file that holds atsconfig.json, so neighbours outside the file's own folder count. - Skipped count. Each folder's progress line and the closing summary line add how many Jev calls were skipped: files that were neither nominated nor already cached. A cached file is never asked either way, so it is not counted.
The graph is read from the checkout on disk, not from --ref. Nomination only works in a tree
that already has some feature folders: where no file sits in one, no file has a current feature,
no file has a pull, and --nominated asks nothing. --nominated changes which files are asked,
never what a file is shown, so it combines with --files and --redact unchanged, and a
nominated row caches like any other.
structure-sweep pairs <folder>=<collapse.json>...
code-graph . --collapse --json > collapse.json
structure-sweep pairs .=collapse.jsonAsks about every collapse candidate and writes .structure-sweep/pairs.json plus a markdown
summary .structure-sweep/pairs.md, which groups same_decision pairs into the functions that
should become one. A pair already answered the same way keeps its answer.
Anchor mode is the default. Each pair is dealt to one of its two functions, the anchor, and
Jev is asked once per anchor rather than once per pair. Functions are taken in order of how many
pairs they appear in, most first, a tie going to the lower path:function. Each takes every pair
not already dealt to an earlier anchor, so no pair is asked twice. The anchor's request sends its
body once, then each candidate partner under a ref (c0, c1, …), and asks one yes/no question
per candidate, same_rule_c0, same_rule_c1, …: does this candidate encode the same business rule
as the anchor? Each candidate is judged on its own, so an anchor with two or more true duplicates
confirms every one of them: pairs.md groups the whole family, and consolidate proposes it as
one collapse group once every link clears its --floor. The same request asks
business_rule about the anchor. One request carries at most --max-questions questions (default
32, JEV_MAX_QUESTIONS, business_rule included); an anchor with more candidates than fit beside
business_rule is asked in one request per chunk, each candidate keeping its ref, and --plan
counts every chunk as a Jev call.
pairs.json still holds one row per pair, with the same fields a pairwise row has, so inventory
and consolidate read both the same way. A row's answers records what its candidate's own
question answered:
- above one half, the candidate is
same_decision, at the probability Jev gave yes; - at or below it, the candidate is
look_alike, at one minus that probability. Anchor mode never recordsshared_helper: a no does not tell plumbing from a look-alike, so shared helpers are found with--pairwiseonly; - a candidate whose question Jev's answer leaves out is
unanswered: its verdict is{ "choice": "unanswered" }, with no confidence, and itspartnerhas noprobability.pairs.mdcounts it on anunansweredline, no group orinventorylever reads it as a verdict, and it is never served from the ledger, so the next run asks its chunk again; business_ruleis the anchor's, the same on every row of its request;partnercarries which side is the anchor, the candidate'sref, itsprobability, amenuhash of the whole state the request was asked about, and the anchor questionversionit was asked under;usageis the one request's, repeated on every row it answered.
The anchor question version and the candidate's ref are part of the cache key, so a row asked
under an earlier version of the question is never served: the first run after an upgrade asks those
anchors again.
Every row, in either mode, carries id, scope, its two functions a and b, the graph's
signals and graphConfidence, answers, model and usage, and two optional fields that are
part of its cache key: redacted (true on a row asked under --redact) and lowered (a hash of
the lowered bodies sent, on a row asked under --graph where at least one side went out lowered).
id hashes the two bodies, so pairs of byte-identical copies share one; pairs.json still holds
one row per scope and pair of functions, so every pasted copy of a rule joins its group.
--pairwise asks the older question instead, once per pair: the three-way same_decision,
look_alike or shared_helper verdict plus business_rule, for gold-set evaluation. Anchor and
pairwise answers never share cached rows, so the first anchor-mode run over a ledger written by an
earlier version asks Jev again. A --pairwise run still reuses those earlier rows.
Judge the whole-tree report. A pair whose two functions sit in different top-level folders (the
same helper copied into two services, say) appears only in a report taken over the repository root,
so per-folder runs (code-graph services/api --collapse with pairs services/api=…) miss it. The
folder is . from the repository root, or any path that resolves to it (.. from packages/). Its
rows record "scope": "." and each side's repo-relative path. A folder outside the repository is
refused. Rows are replaced per scope: a second . run replaces the . rows and leaves every other
scope's rows as they were, and a . run reuses answers a per-folder run already gave on the same
two bodies.
--redact (off by default) shows Jev each function's name and source only, never its file path,
so moving a file cannot move the answer on byte-identical bodies. pairs.json still records both
real paths. A redacted row is marked "redacted": true, and redacted and unredacted runs never
share cached answers: each asks Jev again about a pair the other answered.
What a run costs, and the levers on it
In --pairwise mode Jev is asked once per pair and both functions are sent each time, so cost grows
with the number of pairs, which grows roughly with the square of a cluster of similar functions. A
whole-tree pairwise run over a large monorepo came to 9,140 pairs and about 9.7M tokens. Anchor
mode sends each anchor once and makes one call per anchor. --plan prices either mode before it
spends anything:
structure-sweep pairs .=collapse.json --plan
structure-sweep pairs .=collapse.json --plan --pairwiseIt prints, per folder and in total, the candidate pairs, the distinct functions in them, the
anchors they were dealt to, the pairs skipped over the partner cap, the Jev calls left to make (a
call whose answers are all cached costs nothing) and an estimate of their input tokens, then exits
0. It makes no Jev call, does not read TYPESAFE_API_KEY, and writes nothing. The estimate is the
JSON of the exact request each call would send (state, model and questions, after the cap and the
body choice below) at four characters per token. It is a deterministic stand-in, not the provider's
own count.
--max-partners <n> (default 10) judges each anchor against at most its n best candidate
partners, ranked by the collapse report's confidence (graphConfidence in pairs.json), a tie
going to the lower pair id. Every other pair dealt to that anchor is skipped, never dropped: stderr
counts them, and pairs.md lists each one with both functions and its confidence under skipped
over the partner cap. --pairwise uses the same cap, asking each kept pair on its own. A
--pairwise run with no pair over the cap writes the same pairs.json and pairs.md it always
did.
--graph <graph.json> (off by default) takes the code-graph <folder> --graph --json output for
the one <folder>=<collapse.json> target, and in either mode sends each function's lowered body
instead of its source. pairs runs stage-2 lowering
in process over each function's file at --ref, with no Jev call. A lowered body is the function's
branches rendered as the embedding stage renders them: one paragraph per branch, condition ⇒
outcome, with neutral names for its parameters and locals and no source. A function the graph has
no node for, or that stage 2 leaves unknown or writes no branch for, is sent as its source. A row
Jev answered with a lowered body carries a "lowered" hash of the bodies sent, and lowered and raw
sends never share cached answers.
| Flag | Default | |
|---|---|---|
| --pairwise | off | one three-way question per pair instead of one anchor question per anchor |
| --plan | off | print pairs, anchors, functions and estimated tokens for the mode, then exit without calling Jev |
| --max-partners <n> | 10 | most candidate partners any one anchor is judged against; the rest are skipped and listed |
| --max-questions <n> | 32 | most questions one anchor-mode request carries, business_rule included; at least 2 |
| --graph <graph.json> | none | send stage-2 lowered bodies instead of source; one target only |
| --redact | off | no file paths in what Jev sees |
| --ref <ref> | HEAD | git tree the collapse report was taken from |
| --out <file> | .structure-sweep/pairs.json | judged pairs |
| --report <file> | .structure-sweep/pairs.md | markdown summary |
| --model <id> | jev-1.13.0 | Jev model |
| --concurrency <n> | 6 | calls in flight |
For a whole tree, the cheaper route is per function, not per pair: the
lowering rule-groups path lowers
every function once (stage 2), groups branches that encode one rule deterministically (stage 6),
asks Jev once per candidate cluster rather than once per pair, and hands off owners and collapse
specs (stages 7 and 8), all read back through structure-sweep groups. Keep pairs for a folder,
or for a whole-tree run capped with --max-partners and priced with --plan first.
| Verdict | Means | Action |
|---|---|---|
| same_decision | Both encode the same business rule. | collapse into one function |
| look_alike | Similar shape, different decision. In anchor mode, any candidate Jev did not pick. | keep apart |
| shared_helper | Plumbing with no rule. --pairwise only. | extract a shared helper |
| unanswered | Jev's anchor answer gave the candidate's ref no probability. Listed only when a row is. | run pairs again to ask Jev |
structure-sweep move plan --scope <folder> --feature <key>...
Reads the sweep verdicts and the scope's import graph, and writes
.structure-sweep/move-manifest.json. A file moves only when two opinions agree:
- Jev says move when it put the file in one of the named features at or above
--floor(default0.8). - The graph says move when the file has a pull: count its relative imports, in both directions,
to other files Jev put in a named feature, grouped by that neighbour's feature. The feature
holding a strict majority of those edges is the pull; a tie, or no such edge, is no pull. The
graph is every resolved relative import or re-export between two tracked sources under the scope,
read through the scope's
tsconfig.json, soplanneeds one.
When both name the same feature, the file moves to <scope>/src/<feature>/<role dir>/, carrying
its colocated tests. When only one says move, or they name different features, the file is a
review row carrying both opinions — Jev's feature, role and confidence, and
graph: { feature, share, edges }, the pull's feature (null for none), the share of the counted
edges it holds (absent with no pull) and how many edges were counted. When neither says move, the
file is not listed. These files are pinned — listed, never moved:
package.jsonmain,module,types,binandexportstargets, and a wranglermain. A target inside the build output is traced back to the source that builds it: tsup'sentrymap, then tsconfigoutDir→rootDir, thendist/→src/. Abin: ./dist/index.jspinssrc/index.ts.- Next.js app-router files under
app/orsrc/app/: everypage,layout,route,loading,error,global-error,not-found,templateanddefault, and a rootmiddlewareorinstrumentationfile.
structure-sweep move apply
Carries out the manifest in two commits, so a moved file keeps its git log --follow and blame
history:
- Renames. Every pending row, and each colocated test it carries, is moved with
git mvand its content unchanged, so git scores each as a 100% rename. - Rewrites. ts-morph rewrites the imports and
vi.mock-style specifiers that named a moved file, reading each file from the folder it was written in; any relative specifier still dangling is healed, and the changed files are formatted with the repository's biome when it has one. When none of that changes a file, there is no second commit.
Apply works out every file the rewrite will change before it commits anything. It refuses to start,
naming the paths and writing nothing, while a tracked file has staged or unstaged changes, or while
any file the rewrite would change is not tracked by git. That covers an untracked or gitignored
importer of a moved file, whatever its extension (.mts, .cts, a .js under allowJs) and
however the rewrite loaded it, including through the scope's tsconfig.json include: neither
commit could carry it. Commit, move or delete such a file, then apply again. The lockfile is the one
exception to "tracked": when a dependency is added, a pnpm-lock.yaml git does not yet hold is
committed new, and only one git ignores is refused (see below). An untracked
file the rewrite does not change is left alone and stays out of both commits. A manifest that adds
no workspace dependency is a function of HEAD and the manifest: two applies of it over one HEAD
make the same trees and messages. One that adds a dependency takes its lockfile from the installed
pnpm, so that file can differ between pnpm versions. A run that stopped after the rename commit
resumes with the rewrite commit alone, and a second apply over a finished manifest
commits nothing and reports lint: "untouched". It prints a JSON report, including commits
({ rename, rewrite }, the SHAs it made, null for one it did not), entriesRemoved (below) and
staleStringRefs — old paths still written as plain text somewhere. A row whose source and
destination both exist stops the run.
Apply also reads the pnpm workspace: the packages globs in the repository's
pnpm-workspace.yaml, and each matched package's package.json. The globs are read by the library
pnpm's own package search uses, and skip node_modules and bower_components as pnpm -r does, so
packages/{a,b}, packages/[ab], *, ?, ** and ! all select what they select for pnpm, a
member at packages/test included. A row may move a file from one workspace package into another.
Then the rewrite also loads every workspace package with a TypeScript or JavaScript file that
imports a moved file, and an import the
move touched is written to match where its two ends now sit: across a package line it becomes
<package name>/<subpath>, the subpath read off the target package's exports (wildcard entries
such as "./api/*": "./dist/handlers/*.js" included); inside one package it is relative, and where
a package-name import becomes relative it takes the spelling the importer's tsconfig.json asks
for: ./x.js under NodeNext, ./x otherwise, and ./x.mjs or ./x.cjs for an .mts or .cts
file. Under NodeNext with allowImportingTsExtensions it keeps the TypeScript extension instead
(./x.ts, ./x.mts). An .mjs or .cjs importer always keeps an extension (./x.js), since Node
runs it whatever the tsconfig says. That covers a moved file's own imports back into the package it
left, import x = require(), and a non-TypeScript file such as ./data.json, which keeps its
extension. A moved file's package-name import of a file that stays where it is keeps its spelling.
A row moving a file out of its package often moves a file that package's build names. Apply reads
the package a row leaves the way move plan reads it to pin entry files (above), against the tree
before any rename, so a resumed run reads the same entries. Where a row crosses a package line and
an exports subpath or a tsup entry row of the old package names the moved file, the rewrite
commit removes that entry, since its file has gone: the exports key whole, condition map and all,
and the tsup row from an entry map or array, dts.entry included, whether or not a package.json
field points at it. A target in the build output names the source it follows back to. The report
lists each removal under entriesRemoved, as packages/a/package.json exports ./one or
packages/a/tsup.config.ts entry src/one.ts. Apply never adds an entry, and leaves a wildcard
entry alone: a file only a pattern reaches is named by no entry. A Next.js file convention is not an
entry here.
Refused before either commit, naming the row and what stops it, with HEAD and the tree unchanged:
- a row moving a file its old package names in
main,module,types,bin, a wranglermainor the.export, naming the package and the field, since apply has no sound edit for one; - a row inside one package moving a file an
exportssubpath or tsupentryrow names, since the entry should follow the file. Edit the entry to the new path or repoint the field, commit, and apply again; - a row leaving a package whose tsup
entryapply cannot read from the config's syntax, such as a value held in a variable or built by a call, naming the config file, since apply cannot tell what it names. A tsup config is never executed; - a row whose destination no
exportsentry of its new package reaches, since nothing could import the file there. An entry reaches a file only as Node would answer it: a narrower"./internal/*": nullkeepssrc/internal/x.tsout of a broader"./*", and a wildcard target ending in.jsdoes not reach an.mtsfile, which builds to.mjs; - an import that would cross a package line to a file no
exportsentry reaches, such as a moved file's./data.jsonwhen its old package exports none, or any file other than the one itsmainnames left in a package with noexportsat all; - a relative import in a file crossing a package line that names no file;
- a package-name import into a package a row leaves that apply cannot follow to a file (a target with
no source in the tree, a subpath no entry answers), since a row may be moving that file. A specifier
with a non-code extension, such as
@scope/kit/styles.css, is not refused for this; - an alias from tsconfig
paths, tsconfigbaseUrlorpackage.jsonimports, in a file crossing a package line or naming a moved file, since apply does not rewrite aliases. A bare specifier is read as abaseUrlimport when a file answers it underbaseUrl, as the compiler reads it. What the compiler does not resolve, such as@/Card.vueor#cardwith its target at./src/Card.vue, is read off the alias's own targets, andpathsare read from the nearesttsconfig.jsonor, when it declares none, the first config it references that does; - a backtick path using
${}, in any tracked TypeScript or JavaScript file, when a row changes which file a run-time value loads, whether or not a row crosses a package line. Apply matches its fixed text against tracked files before and after the move, trying what a written specifier could name (no extension,.jsfor.ts, a directory'sindex), so a target moved away, an importer moved away from its targets, a file moved into its reach and a file moved within a wide./${path}are all refused, and a resumed run refuses the same way../locale/${lang}.jsmoved together with every file it can load, keeping their relative layout, applies with the import unchanged. A package-name start such as@scope/a/${name}is matched through the names the package gives each file, so a wildcardexportsentry with no source in the tree matches nothing. A path with no fixed start (${base}/one), a start that only an alias gives meaning (@/locale/${x}) or one naming no tracked file changes nothing; - a quoted string in a tracked
.vue,.svelte,.mdxor.astrofile that a row would leave naming the wrong file, whether or not a row crosses a package line, and a resumed run refuses the same way. Apply does not parse or rewrite these files. It reads every quoted string in them as a relative path, package name or alias would resolve, setting aside a bundler query or hash after the path (./one.ts?raw). So a string naming a moved file is refused, and so are a moved file's string naming one left behind and a string that a row's destination takes over from the file it names now (./onenamingone.jswhen a row moves another file toone.ts). A package-name string into a package a row leaves that apply cannot follow to a file is refused too, as above, though it may name no moved file. Such a file moved together with what it names, keeping their layout, applies unchanged. Prose quoting such a path is refused too, since the scan cannot tell it from an import.
A package with no exports answers its bare name by main (then module, then types) and a deep
path as the file at that path, as Node reads them, so @scope/a/src/one.js is rewritten when a row
moves src/one.ts. Apply sees only the file kinds and module forms it reads. The file kinds, when a
row crosses a package line: tracked .ts, .tsx, .mts, .cts, .js, .jsx, .mjs and .cjs
files, with or without allowJs. A row of either kind, inside one package or across a line, is
refused over an import in a .vue, .svelte, .mdx or .astro file, as the refusal above says, but
apply never rewrites one. That scan does not see a path built at run time, a bundler alias tsconfig
does not declare, an unquoted path, a path that opens with neither ., a package name nor an alias
(/src/one.ts, one.ts, raw-loader!./one), an extension the compiler would not try (./Card
for Card.vue), or any target of an imports condition map or fallback list but the first whose
file exists. Any other file is not read for imports, .md included. The forms:
import, export, import x = require(), require(), import(), a
type-position import("..."), declare module and the vi.* mock calls. A backtick path with no
${} in it is read in require(), import() and the vi.* calls and rewritten like any other,
staying in backticks; one with ${} is checked as the refusal above says. Still unread are string
concatenation such as import("./lib/" + name), a specifier held in a variable, and
import.meta.glob: such a path, or a plain string, is reported under staleStringRefs at best.
Each package whose files now import another workspace package gains "<name>": "workspace:*" in
its package.json dependencies, unless it already names that package in any dependency field.
That includes a package a moved file already imported, which its new package must now declare.
Apply then runs pnpm install --lockfile-only and puts the updated pnpm-lock.yaml in the rewrite
commit with the package.json edits; the report lists them under dependenciesAdded. Both are
worked out before the rename commit, so these refusals leave HEAD and the tree unchanged:
- an added dependency that closes a cycle between workspace packages, named as the chain of
packages (
@scope/a -> @scope/b -> @scope/a), since pnpm could not order them; - a dependency to add while git ignores
pnpm-lock.yaml, since the rewrite commit could not carry the lockfile; - a dependency to add with no
pnpmonPATH; pnpm install --lockfile-onlyfailing.
A manifest that adds no dependency never runs pnpm. Apply adds only workspace:* dependencies: an
npm package a moved file imports is not added to its new package. A repository with no
pnpm-workspace.yaml is one package, so nothing there changes.
A hand-written plan: move apply --plan <file>
When you already know where each file goes, write the moves yourself and skip the sweep:
{
"scope": "app",
"moves": [
{ "from": "app/src/lib/format.ts", "to": "app/src/shared/format.ts" },
{ "from": "app/src/orders/total.ts", "to": "app/src/billing/total.ts" }
]
}scope is the folder whose tsconfig.json apply loads, and every path is repo-relative;
./packages/a/x.ts and packages/a/x.ts are one path. A row may cross a workspace package line,
with the rewrite, dependencies and refusals described above; move plan never writes such a row, so
this is how you ask for one. Name the package the files leave as scope. Importers in other packages
are loaded whichever package you name. A row
holds from and to and nothing else: Jev's feature, role and confidence, or a manifest's
features, floor, review and pinned, are refused, so a hand plan never reads as a sweep's
judgment. List each file on its own row, colocated tests included; there are no folder rows. A plan
where two rows share a from, or two rows share a to, is refused too, naming both rows. Every
refusal happens before anything is committed.
Apply carries out the plan in the same two commits, with the same resume and the same no-op re-run
as a manifest. Both commit messages say the moves were hand-planned, and both carry a
Structure-Sweep-Plan: <hash> trailer naming the plan's content hash in place of the manifest's.
--plan cannot be combined with --manifest: passing both is an error naming both flags. Without
--plan, apply reads the manifest as before.
structure-sweep score
structure-sweep score --since 2026-01-01 --pr-onlyGrades the sweep's file-to-feature assignment against git history, on the idea that files in one
feature change together. It reads .structure-sweep/verdicts.json and git log, prints a table
and writes .structure-sweep/score.json. It calls no model and no network.
Each non-merge commit is one change set: the paths it touched that have a verdict row, deduped.
Only change sets with 2 to --max-files such paths are kept. A pair of files counts only when both
rows share a scope, the folder that was swept.
| Metric | Means |
|---|---|
| precision | Of the same-scope pairs in one feature, among files some kept change set touched, the share that changed together at least once. |
| recall | Of the same-scope pairs that changed together, the share in one feature. |
| f1 | 2PR / (P + R). |
| per-feature precision / recall / f1 | Precision over the pairs inside the feature; recall is co-changed pairs with both files in it over co-changed pairs with at least one. |
| leaf folders | The same precision, recall and f1 with each file's own folder as its label: the number the vocabulary has to beat. |
| confident | Rows whose feature confidence is at least 0.8, overall and per feature, with the counts behind it. |
A metric whose denominator is zero is null in the JSON and n/a in the table.
| Flag | Default | |
|---|---|---|
| --verdicts <file> | .structure-sweep/verdicts.json | sweep output to grade |
| --ref <ref> | HEAD | read history back from here |
| --since <date> | all history | only commits after this date, as git log --since takes it |
| --pr-only | off | only commits whose subject ends in (#N), the squash-merge shape |
| --max-files <n> | 40 | drop commits touching more labelled files than this |
| --out <file> | .structure-sweep/score.json | JSON report |
structure-sweep consolidate
structure-sweep consolidate --max-lines 40 --min-cluster 3Turns outputs already on disk into a consolidation plan: which tiny files to merge, which copies of
one business rule to collapse, and which plumbing to extract. It calls no model and no network, needs no TYPESAFE_API_KEY, and changes no
source file — it writes .structure-sweep/consolidate.json and a markdown summary
.structure-sweep/consolidate.md, nothing else. The same inputs give byte-identical JSON.
- merge — from the sweep verdicts: files sharing one
scope, feature and role whose non-blank line count at--refis at most--max-lines. A group with at least--min-clustersuch files is one proposal. - collapse — from
pairs.json:same_decisionpairs at confidence--flooror above (default 0.8), joined into one group wherever two pairs share a function, within a scope. The pairs may come from different anchors' requests: three copies of one rule are one group whether one anchor confirmed both others or each pair was asked from a different anchor. A pair under the floor, alook_alikeand anunansweredpair join nothing, so every function in a group is linked to it by confirmations at the floor, andconfidenceis the weakest of them. The floor is wheremovetrusts a verdict rather than anchor mode's bare yes of one half, because a group is every function one chain of links reaches: one weak link would fold a second rule into the group. - extract — from
pairs.json: onlyshared_helperpairs, joined into one candidate wherever two pairs share a function, within a scope.
A missing input skips its kinds with a line on stderr naming the file, and those kinds are null in
the plan; a malformed one fails the run with its parse error.
{
"ref": "HEAD", "maxLines": 40, "minCluster": 3, "floor": 0.8,
"merge": [{ "scope": "apps/web/src", "feature": "billing", "role": "ui",
"files": [{ "path": "apps/web/src/billing/price.tsx", "lines": 12 },
{ "path": "apps/web/src/billing/tax.tsx", "lines": 9 },
{ "path": "apps/web/src/billing/total.tsx", "lines": 7 }], "lines": 28 }],
"collapse": [{ "scope": "apps/web/src",
"members": ["docs/allowed.ts:isEditAllowed", "docs/edit.ts:canEditDocument",
"docs/modify.ts:mayModifyDoc"], "pairs": 3, "confidence": 0.85 }],
"extract": [{ "scope": "apps/web/src", "members": ["a.ts:fetchA", "b.ts:fetchB"], "pairs": 1 }]
}Merge proposals are sorted by file count; collapse groups and extract candidates by member count, then pair count.
| Flag | Default | |
|---|---|---|
| --verdicts <file> | .structure-sweep/verdicts.json | sweep output |
| --pairs <file> | .structure-sweep/pairs.json | pairs output |
| --ref <ref> | HEAD | tree the line counts are read from |
| --max-lines <n> | 40 | a file is small at or under this many non-blank lines |
| --min-cluster <n> | 3 | small files a group needs to become a proposal (at least 2) |
| --floor <0..1> | 0.8 | a same_decision pair joins a collapse group at or above this confidence |
| --out <file> | .structure-sweep/consolidate.json | JSON plan |
| --report <file> | .structure-sweep/consolidate.md | markdown summary |
structure-sweep groups
structure-sweep groups list
structure-sweep groups show g-3f0c9a1b2d4e --graph graph.jsonReads the lowering pipeline's stage 6 to 8 artifacts
back as JSON on stdout, so an agent resolving a group can pull the evidence behind it without
re-running the pipeline or reading its internals. It reads the artifacts file only: no Jev call, no
network, no TYPESAFE_API_KEY, and the same artifacts print byte-identical output. It never writes
source and never asks Jev anything. unknown stays unknown.
The artifacts file is what writeGroupsFile(path, groupsFileOf({ confirmed, owners, specs }))
writes after a run of stages 6 to 8: the stage-6 confirm artifact, the stage-7 owner artifact (or
null before stage 7 ran) and the stage-8 task specs, each artifact with the key and digest it was
stored under. readGroupsFile refuses a file that does not parse, listing every problem.
list prints every rule group and every candidate cluster that did not become one. state is
confirmed (a rule group), rejected (Jev said the members are not one rule) or abstained
(below the floor after every round). owner is set only on a confirmed group once stage 7 has run.
{
"groups": [
{ "id": "g-3f0c9a1b2d4e", "state": "confirmed", "signal": "condition", "members": 3,
"owner": { "group": "g-3f0c9a1b2d4e", "member": "src/archive/guard.ts:guardArchive@3",
"function": "src/archive/guard.ts:guardArchive",
"span": { "file": "src/archive/guard.ts", "startLine": 4, "endLine": 4 } },
"spec": true },
{ "id": "g-8d21e07c55a0", "state": "confirmed", "signal": "condition", "members": 3,
"owner": "unknown", "spec": false },
{ "id": "g-c4b7f2e9a013", "state": "rejected", "signal": "condition", "members": 2,
"owner": null, "spec": false }
]
}show <id> prints one group or cluster (abbreviated here to one member and one candidate, with the
owner answer's distribution cut):
{
"id": "g-8d21e07c55a0",
"state": "confirmed",
"basis": { "_tag": "condition", "key": { "atoms": ["v0", "¬(v0.canArchive)"], "outcome": "deny" } },
"members": [
{ "branch": "src/archive/check.ts:archiveIsAllowed@1", "function": "src/archive/check.ts:archiveIsAllowed",
"span": { "file": "src/archive/check.ts", "startLine": 3, "endLine": 3 },
"atoms": ["v0", "¬(v0.canArchive)"], "outcome": "return false", "bindings": [],
"callers": ["src/ui/menu.ts:items"] }
],
"owner": {
"candidates": [
{ "function": "src/archive/check.ts:archiveIsAllowed", "member": "src/archive/check.ts:archiveIsAllowed@1",
"span": { "file": "src/archive/check.ts", "startLine": 3, "endLine": 3 }, "layer": null, "fanIn": 1 }
],
"settledBy": "jev",
"owner": "unknown"
},
"evidence": {
"differences": [{ "ref": "m0", "atoms": [], "outcome": "return false" }],
"answer": { "label": "same-rule", "confidence": 0.95,
"probabilities": { "same-rule": 0.95, "related-different": 0.025, "unrelated": 0.025 } },
"rounds": 0
},
"questions": [
{ "stage": "owner", "span": { "file": "src/archive/check.ts", "startLine": 3, "endLine": 3 },
"tied": ["src/archive/check.ts:archiveIsAllowed", "src/archive/guard.ts:guardArchive"],
"answer": { "label": "c0", "confidence": 0.1, "probabilities": { "c0": 0.1, "…": 0.1 } },
"rounds": 1 }
],
"spec": null
}members: each member branch with itsSourceSpan, its lowered atoms and outcome, andcallers, the member function'sedges.calledByfrom the--graphJSON.owner: every candidate stage 7 narrowed to (not only the winner), what settled it (layer,fan-inorjev, ornonewhen no step could and nothing was asked) and the owner, orunknown.nullfor a cluster that is not a group, or before stage 7 ran.evidence: stage 6's confirm evidence: the differences Jev was shown and the answer the gate settled on, with Jev's whole distribution.questions: the human-queue entries stage 6 (group-confirm: a rejected or abstained cluster) or stage 7 (owner: an owner that isunknown) wrote for this group.spec: the stage-8 task spec, ornullwhen the group has none.
| Flag | Default | |
|---|---|---|
| --artifacts <file> | .structure-sweep/groups.json | the groups file |
| --graph <file> | (required for show) | code-graph --graph JSON, for each member's callers |
structure-sweep inventory
code-graph . --unreachable --json > unreachable.json
code-graph . --graph > graph.json
structure-sweep inventory --unreachable unreachable.json --graph graph.json --exclude 'packages/design/**'Joins outputs already on disk into one consolidation list: every deletable or mergeable thing, with
file and lines, biggest lever first. It writes .structure-sweep/inventory.json and
.structure-sweep/inventory.md and nothing else. It calls no model and no network, needs no
TYPESAFE_API_KEY, and runs no other command — not sweep, pairs, consolidate or code-graph;
running those stays the caller's job. The same inputs give byte-identical JSON and markdown.
Inputs, each by its own flag:
--unreachable <file>:code-graph --unreachable --jsonoutput, or a--graph --unreachabledump. No default.--graph <file>: acode-graph --graphJSON. No default.--consolidate <file>:consolidateoutput, default.structure-sweep/consolidate.json.--pairs <file>:pairsoutput, default.structure-sweep/pairs.json.--groups <file>: the lowering stage 6 to 8 groups filewriteGroupsFilewrote, default.structure-sweep/groups.json. Read where present.
Run code-graph on the repository root, so its paths are repo-relative like every other input's.
Levers, in the order the inventory lists them:
| Lever | Reads | One entry per | Deletions |
|---|---|---|---|
| A dead exports | --unreachable | unreachable export; dead (no reference) and only-called-from-tests stay distinct signals and actions | lines of the export; its end line comes from the --graph JSON (or the dump), else it is the start line |
| B tiny-file merges | --consolidate | merge proposal, as consolidate made it | files that go: all but one |
| C same decision | --pairs | group of same_decision pairs at confidence >= 0.5, joined wherever two pairs share a function | lines of every copy but the largest |
| D shared helper | --pairs | family of shared_helper pairs at confidence >= 0.7 with the same function name(s) | lines of every copy but the largest |
| E exported-name twins | --graph | exported, non-test name found in two or more top-level scopes (a file's first two directories); generic names such as GET, handler or default excluded | lines of every copy but the largest |
| F rule groups | --groups | confirmed rule group | lines of every member but the largest |
Entries are ordered by lever, then by deletions, biggest first. Each carries an id hashed from its
lever, subject, spans and signals (the same content, the same id, on every run), its lever, a
subject, its source signals, spans as { file, startLine, endLine }, the weakest verdict
confidence behind it (null where the source judges nothing: A, B and E), its deletions and a
suggested action.
{
"version": 1,
"levers": [
{ "lever": "dead-export", "state": "built", "input": "unreachable.json", "entries": 1 },
{ "lever": "tiny-file-merge", "state": "skipped", "flag": "--consolidate",
"path": ".structure-sweep/consolidate.json", "reason": "no such file" }
],
"entries": [
{ "id": "A-be9199777a65", "lever": "dead-export", "subject": "src/old.ts:unusedLong",
"signals": ["unreachable:dead"],
"spans": [{ "file": "src/old.ts", "startLine": 5, "endLine": 24 }],
"confidence": null, "deletions": 20, "action": "delete the export" }
]
}A missing input skips its levers. The lever is listed with state: "skipped", the flag and the
path it looked for (null when a flag with no default was not given) in the JSON, and as
skipped: … in the markdown; the command still exits 0 with every lever it could build. B is also
skipped when consolidate ran without sweep verdicts, so its plan has no merge. An input that
exists but does not parse fails the run.
--exclude <glob> (repeatable) keeps matching files out of lever B: no tiny-file merge lists an
excluded file, and a proposal left with fewer files than consolidate's --min-cluster is dropped.
** spans directories, * and ? stay inside one, and a plain path excludes everything under it.
| Flag | Default | |
|---|---|---|
| --unreachable <file> | (none) | lever A |
| --graph <file> | (none) | lever E, and lever A's end lines |
| --consolidate <file> | .structure-sweep/consolidate.json | lever B |
| --pairs <file> | .structure-sweep/pairs.json | levers C and D |
| --groups <file> | .structure-sweep/groups.json | lever F |
| --exclude <glob> | (none) | files kept out of lever B, repeatable |
| --out <file> | .structure-sweep/inventory.json | JSON inventory |
| --report <file> | .structure-sweep/inventory.md | markdown inventory |
As a library
Every command except propose is also a function — runSweep, runPairs, planScope /
planManifest, applyManifest / applyHandPlan, scoreCoChange over rows and change sets with readChangeSets as
its git reader, mergeProposals / collapseProposals / extractProposals / renderConsolidation for
consolidate, and readGroupsFile / listGroups / showGroup for groups, and buildInventory /
renderInventory for inventory — and each
Jev-calling one takes its JevClient as an argument, so a caller can hand it a stub.
Lowering: stage artifacts, the evaluation harness and the confidence gate
The foundation the staged lowering pipeline builds against. No command uses it yet: each piece is a library function, and each Jev-calling one takes its client as an argument.
Stage artifacts. A Stage<V> has a name, a version and a run that writes Fact<V>s. Every
fact carries a SourceSpan (file plus 1-based inclusive lines), and its value is either known,
with the basis it is known on (derived from source, or promoted by the gate), or unknown, with
a reason. There is no arm for a guess, and unknown carries no answer. runStage(store, stage,
input) keys the run on the stage name, its version, the hash of input.content and the digest
of every input artifact, in order. An unchanged key is a hit answered from the store without
running the stage; changing any part is computed. memoryArtifactStore() is the store.
Evaluation harness. evaluate({ question, gold, connect, thresholds, target }) asks every
gold item under the question's own instructions and under each rewording, through the client
connect builds for that wording. It reports:
accuracy: the share of all answers, under every wording, that match the gold label.flipRate: the share of items whose label is not the same under every wording.ece: the expected calibration error over all answers, in equal-width confidence bins (bins, 10 by default).calibration: accuracy per non-empty confidence band, and the stage's floor derived from it (below).coverage: the share of items whose answer under the question's own wording clears that floor, so the gate would promote them on the first ask.abstainRateis the rest.verdict:shippableonly when ECE and flip rate are both strictly under their thresholds, elsenot-shippablenaming each metric that is not.
bins must be a positive whole number and every confidence Jev returns must be in [0, 1]. Anything
else is a RangeError, never a skipped answer, so a bad input cannot pull ECE down to 0 and read
shippable. expectedCalibrationError refuses the same inputs. evaluate also refuses a gold set
with no items or no rewording with a GoldSetError, before it asks Jev anything: no items would score
ECE and flip rate 0 on no answers, and one wording alone can never flip. GoldSet types both arrays
as non-empty, and evaluate checks again for a set built outside the type system.
The floor is derived, per stage. calibrate(scored, { target, bins }) bins scored answers by
confidence and walks down from the top band. The floor is the lower edge of the lowest band such that
it and every non-empty band above it are at least target accurate, with empty bands skipped. That
is { _tag: "derived", floor, target, bands }. When even the top band misses the target, the result
is { _tag: "unreachable" } and no answer from that stage can be promoted. evaluate returns this as
calibration, and calibrate is the only way to build a derived one.
Gold-set format. A gold set is a JSON file read by loadGoldSet(file, labels):
{
"stage": "branch-label",
"rewordings": ["Does this branch decide who may do something, or only move data?"],
"items": [
{
"id": "a",
"span": { "file": "src/access/can-edit.ts", "startLine": 12, "endLine": 18 },
"state": { "source": "if (user.role !== \"owner\") return deny();" },
"gold": "gate"
}
]
}rewordings holds at least one alternative to the question's instructions. Each item's state
is what Jev is shown, and gold must be one of the question's criteria keys. An unknown label or a
repeated id is refused with every problem listed.
Confidence gate. gatePolicy({ calibration, maxRounds }) sets one stage's rule. It takes the
stage's derived calibration and reads the floor off it: there is no default floor and no way to pass
a bare number. gate(item, { policy, ask, enrich }) promotes an answer at or above the floor. A below-floor item is passed to enrich
for more context and asked again, for at most maxRounds rounds, and then it abstains. decide
is the pure step, and its outcome is promoted, retrying (with the round about to run) or
abstained. gateAll(stage, items, options) returns the facts for the next stage and a
HumanQueue holding each abstained item's span, final answer and round count. An abstained item's
fact is unknown, so no downstream stage reads it as an answer.
gateAll also returns settled, each item's final outcome in item order. A stage whose answer
carries more than a label and a confidence types it as the gate's second parameter
(GateOptions<K, J>), and the gate returns that answer unchanged in settled and in the queue.
Lowering stages 2–5: branches, the lexicon, callee summaries and the branch label
Four stages built on that foundation. Each is a library export and none is a command yet. Stage 5 is the only one that asks Jev.
Stage 2, lowering (lowerStage). loweringInput({ file, source, functions, loggingRoots })
builds a file's input from its text and the --graph JSON function nodes (readLoweringGraph
reads id, file, startLine, endLine and edges.calls). The file is parsed with oxc-parser.
For every function the graph names in the file, the stage writes one Fact<LoweredBranch> per
return, per throw, and per call statement reached under a non-empty path condition. Each fact
carries:
- A path condition made of atoms, each with its own span. An atom is one comparison, one
predicate call, one
in/instanceof, or one truthiness test.&&is flattened into the conjunction,!is the atom's po
