npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 vocabulary sweep reads, 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-sweep

Jev 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.json

Writes 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 --depth levels, each with the number of files sweep would classify), every named workspace package.json, and, from each --graph file, the code-graph clusters that span several folders (the graph needs code-graph --graph --clusters; each cluster names the file it came from, since two graphs number theirs independently) and every cross-runtime service.method call. 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:

  1. propose — structure-sweep propose <folder>....
  2. draft — read propose-prompt.md and write the config to .structure-sweep/proposed.config.json.
  3. sweep — structure-sweep sweep <folder>... --config .structure-sweep/proposed.config.json.
  4. score — structure-sweep score.
  5. 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.json

One 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:

  1. scores the assignment with the same scorer as score: the --verdicts rows, with every --sweep row laid over the row for its path. A swept row brings its judgement and keeps the verdict row's scope, since sweep --files records 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.
  2. 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-files of 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's files and the at-least-4-files threshold, so a feature can clear the threshold with its cohesion read from fewer files. Cohesion is the feature's precision in the per-feature cohesion entries (features in refine.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's signals.basis is co-change and the prompt says the diagnostics are co-change only.
    • plateau status.
  3. 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 --threshold for --patience consecutive runs.
  4. 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 paths sweep --files accepts at --ref are listed.
  5. 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:

  1. refine — structure-sweep propose refine.
  2. edit — change .structure-sweep/proposed.config.json as refine-prompt.md says.
  3. sweep the sample — structure-sweep sweep --files .structure-sweep/refine-sample.txt --config .structure-sweep/proposed.config.json --out .structure-sweep/refine-sweep.json.
  4. 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/src

Reads 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 -, as move plan names 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 plan does (resolved relative imports and re-exports between tracked sources, through the nearest tsconfig.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 --files the graph spans the nearest folder at or above each listed file that holds a tsconfig.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.json

Asks 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 records shared_helper: a no does not tell plumbing from a look-alike, so shared helpers are found with --pairwise only;
  • a candidate whose question Jev's answer leaves out is unanswered: its verdict is { "choice": "unanswered" }, with no confidence, and its partner has no probability. pairs.md counts it on an unanswered line, no group or inventory lever reads it as a verdict, and it is never served from the ledger, so the next run asks its chunk again;
  • business_rule is the anchor's, the same on every row of its request;
  • partner carries which side is the anchor, the candidate's ref, its probability, a menu hash of the whole state the request was asked about, and the anchor question version it was asked under;
  • usage is 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 --pairwise

It 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 (default 0.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, so plan needs 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.json main, module, types, bin and exports targets, and a wrangler main. A target inside the build output is traced back to the source that builds it: tsup's entry map, then tsconfig outDir → rootDir, then dist/ → src/. A bin: ./dist/index.js pins src/index.ts.
  • Next.js app-router files under app/ or src/app/: every page, layout, route, loading, error, global-error, not-found, template and default, and a root middleware or instrumentation file.

structure-sweep move apply

Carries out the manifest in two commits, so a moved file keeps its git log --follow and blame history:

  1. Renames. Every pending row, and each colocated test it carries, is moved with git mv and its content unchanged, so git scores each as a 100% rename.
  2. 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 wrangler main or 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 exports subpath or tsup entry row 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 entry apply 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 exports entry 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/*": null keeps src/internal/x.ts out of a broader "./*", and a wildcard target ending in .js does not reach an .mts file, which builds to .mjs;
  • an import that would cross a package line to a file no exports entry reaches, such as a moved file's ./data.json when its old package exports none, or any file other than the one its main names left in a package with no exports at 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, tsconfig baseUrl or package.json imports, in a file crossing a package line or naming a moved file, since apply does not rewrite aliases. A bare specifier is read as a baseUrl import when a file answers it under baseUrl, as the compiler reads it. What the compiler does not resolve, such as @/Card.vue or #card with its target at ./src/Card.vue, is read off the alias's own targets, and paths are read from the nearest tsconfig.json or, 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, .js for .ts, a directory's index), 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}.js moved 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 wildcard exports entry 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, .mdx or .astro file 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 (./one naming one.js when a row moves another file to one.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 pnpm on PATH;
  • pnpm install --lockfile-only failing.

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-only

Grades 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 3

Turns 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 --ref is at most --max-lines. A group with at least --min-cluster such files is one proposal.
  • collapse — from pairs.json: same_decision pairs at confidence --floor or 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, a look_alike and an unanswered pair join nothing, so every function in a group is linked to it by confirmations at the floor, and confidence is the weakest of them. The floor is where move trusts 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: only shared_helper pairs, 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.json

Reads 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 its SourceSpan, its lowered atoms and outcome, and callers, the member function's edges.calledBy from the --graph JSON.
  • owner: every candidate stage 7 narrowed to (not only the winner), what settled it (layer, fan-in or jev, or none when no step could and nothing was asked) and the owner, or unknown. null for 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 is unknown) wrote for this group.
  • spec: the stage-8 task spec, or null when 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 --json output, or a --graph --unreachable dump. No default.
  • --graph <file>: a code-graph --graph JSON. No default.
  • --consolidate <file>: consolidate output, default .structure-sweep/consolidate.json.
  • --pairs <file>: pairs output, default .structure-sweep/pairs.json.
  • --groups <file>: the lowering stage 6 to 8 groups file writeGroupsFile wrote, 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. abstainRate is the rest.
  • verdict: shippable only when ECE and flip rate are both strictly under their thresholds, else not-shippable naming 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