diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac84ecd..8d8ab6f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,20 +15,34 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph_explore` no longer re-sends source it already returned earlier in the same conversation. A file it has already shown you comes back as a short pointer — the path, the symbols and the exact line range, with confirmation that the file hasn't changed since — and the space that frees is spent on code you haven't seen yet, so a follow-up call covers new ground instead of repeating the last one. If a file was edited in between, its source is always shown again in full. Set `CODEGRAPH_EXPLORE_DEDUP=0` to turn this off.
+- When an agent connects over MCP, CodeGraph now states up front that it indexes 30+ languages — TypeScript/JavaScript, Python, Go, Rust, Java, C#, C/C++, PHP, Ruby, Swift, Kotlin, and more — so agents no longer assume a language isn't supported and skip the graph. (#671)
+
+- GitHub Copilot is now a supported agent: `codegraph install` can configure Copilot Chat in VS Code (`copilot-vscode`), the GitHub Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`). Installed Copilot surfaces are auto-detected like every other agent, existing MCP server entries in their config files are preserved, and `codegraph uninstall` reverses the setup cleanly. Restart VS Code or your JetBrains IDE after installing so Copilot picks up the server.
+
### Fixes
- C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515)
+- A long-lived index no longer drifts away from what a fresh `codegraph index` would produce. When a file gained or lost a symbol, references to that name in files the sync never touched kept pointing at the definition that was correct before the change, and — because nothing distinguished two same-named definitions — the winner could come down to the order files happened to be written, which differs between a full index and a sync. On this project's own repository, replaying 80 commits through `sync` left 5.7% of connections wrong; it is now 1.3%, and the wrong-answers-still-being-asserted half drops by 99.7%. Since call edges are what flow questions follow and what `codegraph_explore` ranks files by, this quietly degraded answers as an index aged, with nothing to indicate it. Syncing is unchanged in speed, and an edit that only changes a function's body does no extra work at all. Set `CODEGRAPH_NO_REBIND=1` to opt out.
- `codegraph_explore` now concentrates its answer on the code that actually answers your question instead of spreading it across files that merely share a word with it, so more of the answer arrives in a single call. Thanks @LeDuyViet for the detailed measurements and reproduction. (#1500)
- Files only weakly related to your question now come back as a name, symbol and line number instead of spending the answer on their source — name one of them in a follow-up `codegraph_explore` to get it back in full. (#1500)
- A generated CRUD or protobuf layer no longer crowds out the hand-written code sitting beside it: generated files are now recognized by the `// Code generated by … DO NOT EDIT.` style banner written at the top of the file, not just by a filename that looks generated. Re-index after upgrading to pick up the new detection. (#1500)
- Test and spec files in a repository's top-level `test/` or `spec/` directory are now recognized as such, so they no longer take room from the code you asked about. (#1500)
+- Generated type-declaration files that announce themselves with a "Generated by … by running …" banner — Cloudflare Wrangler's `worker-configuration.d.ts` is the common one — are now recognized as generated. Previously a file like that could take most of a `codegraph_explore` answer on nothing more than a few common words, pushing the hand-written code you asked about out of the response entirely. Re-index after upgrading to pick up the new detection.
+- A hand-written type-declaration file — an ambient `.d.ts` of global shims, vendored typings, module augmentation — no longer takes over a `codegraph_explore` answer about how something works. Files like these declare common names (`Body`, `Message`, `ImageMetadata`) and nothing else, so a plainly-worded question could match one strongly enough that it ranked first and crowded the actual handler out of the answer. They are now ranked lower for questions about behaviour, and are still listed by name so one follow-up call fetches them. Asking about a type by name still returns its declaration first, and a shared types module the rest of your code imports is unaffected.
- A CodeGraph process that gets force-killed — by the stuck-process watchdog, a crash, or the OS — no longer leaves the database's write-ahead log behind to grow without bound. Previously each killed session stacked more data onto the same log file and nothing ever shrank it, which on machines where sessions were killed regularly could quietly eat tens of gigabytes of disk. The log is now capped, and any oversized leftover is reclaimed automatically the next time the project is opened. Thanks @tiendungdev for the exceptional Windows report that pinned this down. (#1431)
- The background server's watchdog no longer kills a healthy server that is just waiting on a slow disk: like indexing already does, it now checks whether the database files are still making progress before concluding the process is stuck. Fewer spurious kills also means fewer leftover write-ahead logs. (#1431)
- `codegraph status` now shows the write-ahead log's size next to the database size and warns when killed sessions have left it oversized, and every line in the background server's log now carries a timestamp so kills and restarts can be placed in time. (#1431)
- On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466)
- Python classes used as values — `return SomeSerializer` from a factory method, `handler = SomeClass` aliases, registry dicts and lists, and classes passed as arguments — now produce reference edges in the graph. Previously these idioms were invisible, so on Django and Django REST Framework projects, asking for a serializer's callers or the impact of editing it missed the views that actually use it. Re-index after upgrading to pick up the new edges. (#1478)
- When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474)
+- A file built around one very long function no longer takes the whole `codegraph_explore` answer for itself — or disappears from it. Previously such a file was shown in full however big it was, which used up the room every file after it needed, and when the function was larger than the entire response the file was dropped without a word. These files now come back as a bounded window on whole lines — the signature and the top of the body, plus the call site when the call path runs through it — with the rest one follow-up `codegraph_explore` away.
+- `codegraph_explore` no longer lets the first file in an answer spend the room set aside for the files below it, so the rest of the answer still arrives. Previously a large file near the top could quietly use up everything left, and the files ranked under it — each already judged relevant enough to include — were dropped with no source at all; on one question only one of six made it into the answer. Every file now keeps what it was given, and a question that really is about one file still concentrates on that file.
+- When a `codegraph_explore` answer runs right up against its size limit, it now drops the trailing notes rather than a whole file's source. Previously the last file was cut even though trimming the notes alone would have fit, so a file that had already been read, ranked and rendered was thrown away at the last moment. Across a range of real projects this returns one more file and up to 20% more source per call.
+- Every file `codegraph_explore` decides to include now actually arrives. A file shown in full could still spend room set aside for files below it — the fix above covered files shown as excerpts but not files shown whole — and the answer's own size bookkeeping under-counted each file's heading, so the answer ran past its limit and a fully prepared file was discarded at the end. A file that no longer fits whole is now shown as excerpts instead of vanishing, and one that overshoots by a little is trimmed to fit rather than dropped.
+- The list of files an answer could not cover — the "explore these names for their source" pointers — is no longer thrown away when the answer is full. It is now budgeted for and trimmed to fit, so a full answer still tells you what it left out and which names to ask for next, instead of ending with no pointers at all.
+- When a `codegraph_explore` answer shows a file as excerpts, a large excerpt that no longer fit was dropped entirely instead of being shortened. If the file's first excerpt happened to be a trivial one — an import block, a one-line helper next to the code you asked about — the excerpt carrying the actual answer was the one thrown away, and the file came back with a quarter of the room it had been given. On real projects that meant the top-ranked file delivered a fraction of its share while a far less relevant file took the rest. Excerpts are now shortened to fit, whole method by whole method, and only dropped when what is left is too small to hold anything readable.
+- When you name a symbol in a `codegraph_explore` query, its definition now actually comes back. Two cases previously lost it. If the symbols you named don't call one another — sibling functions inside the same factory or module are the everyday example — CodeGraph stopped treating them as symbols you had asked for, and answered with whatever sat at the top of their file instead; on one 1,400-line file that meant a same-stem `QueuedMessage` interface on line 70 came back while the `queueMessage` function on line 1087 did not. And when an answer had to be trimmed to fit, it was trimmed from the bottom of the file down, so a symbol near the end of a long file was always the first thing cut. Trimming now protects the definitions you named wherever they sit in the file.
- The blast-radius section of `codegraph_explore` flagged "no covering tests found" whenever no test called a symbol directly — falsely branding helpers that tests exercise through their callers as untested (about 40% of flagged symbols in a measured sample). The check now follows caller chains up to 3 hops and reports indirect coverage as "tested via callers"; when nothing is found it states exactly what was checked instead of an unconditional warning. Thanks @inth3shadows for measuring the false-positive rate. (#1475)
## [1.5.0] - 2026-07-21
diff --git a/README.md b/README.md
index 903ae0c..f8bb60b 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Already installed? Run `codegraph upgrade`
Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates.
-### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence
+### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, and GitHub Copilot with Semantic Code Intelligence
**The fastest complete code graph · surgical context · built for how agents actually work · 100% local**
@@ -35,6 +35,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates.
[](#supported-agents)
[](#supported-agents)
[](#supported-agents)
+[](#supported-agents)
@@ -104,7 +105,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you
codegraph install
```
-Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.)
+Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot (VS Code, Copilot CLI, JetBrains IDEs) — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.)
### 3. Initialize each project
@@ -375,7 +376,7 @@ npx @colbymchenry/codegraph
```
The installer will:
-- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**
+- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**, **GitHub Copilot** (VS Code, Copilot CLI, JetBrains IDEs)
- Prompt to install `codegraph` on your PATH (so agents can launch the MCP server)
- Ask whether configs apply to all your projects or just this one
- Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`.
@@ -389,7 +390,9 @@ The installer **wires up your agents only — it does not index your code.** Aft
codegraph install --yes # auto-detect agents, install global
codegraph install --target=cursor,claude --yes # explicit target list
codegraph install --target=auto --location=local # detected agents, project-local
+codegraph install --target=copilot-vscode,copilot-cli,copilot-jetbrains --yes # GitHub Copilot everywhere
codegraph install --print-config codex # print snippet, no file writes
+codegraph install --print-config copilot-vscode # same, for Copilot in VS Code
```
| Flag | Values | Default |
@@ -402,7 +405,7 @@ codegraph install --print-config codex # print snippet, no file wr
### 2. Restart Your Agent
-Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro) for the MCP server to load.
+Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / VS Code, the Copilot CLI, or your JetBrains IDE for GitHub Copilot) for the MCP server to load.
### 3. Initialize Projects
@@ -760,6 +763,7 @@ is written):
- **Gemini CLI**
- **Antigravity IDE**
- **Kiro**
+- **GitHub Copilot** — Copilot Chat in VS Code (`copilot-vscode`), the Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`)
## Supported Languages
@@ -858,7 +862,7 @@ MIT
-**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro**
+**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot**
[Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues)
diff --git a/__tests__/explore-cluster-starvation.test.ts b/__tests__/explore-cluster-starvation.test.ts
new file mode 100644
index 0000000..0035348
--- /dev/null
+++ b/__tests__/explore-cluster-starvation.test.ts
@@ -0,0 +1,170 @@
+/**
+ * Regression gate for CLUSTER-LEVEL STARVATION inside one file (task CG-36).
+ *
+ * A file's ranked clusters used to be all-or-nothing past the first one: the
+ * top-ranked cluster was taken (shrunk to fit if it had to be), and every
+ * cluster below it was rendered whole and then either fit the remainder or was
+ * dropped entirely. On a file whose top-ranked cluster is TRIVIAL that discards
+ * the answer — django's `db/models/sql/query.py` kept a 22-line glue cluster and
+ * dropped the 624-line `Query` body beneath it, spending 1,923 of a 7,947
+ * reservation, and okhttp's `RealInterceptorChain.kt` did the same behind its
+ * import header.
+ *
+ * What makes it hard to see is that the response stays FULL: the unspent
+ * reservation carries forward exactly as designed, so a lower-scoring file takes
+ * the bytes and every envelope-share measure still looks healthy. The gate is
+ * therefore per-file spend, not share.
+ *
+ * Two fixtures, pulling in opposite directions — read them together:
+ *
+ * - `starved-cluster-ts` is the defect. Its answer-bearing cluster must be
+ * SHRUNK into whatever the trivial cluster left, not dropped.
+ * - `dense-header-ts` is the Session.swift shape that cluster ranking puts
+ * importance ahead of density FOR. Its query's methods sit ~200 lines under
+ * a dense property list, and they must keep winning the budget. Any future
+ * rework of selection or shrinking has to satisfy both.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+import type { ExploreDiagnosticReport } from '../src/mcp/explore-diagnostics';
+
+interface Run {
+ dir: string;
+ cg: CodeGraph;
+ response: string;
+ report: ExploreDiagnosticReport;
+}
+
+/** Copy a fixture tree to a temp dir, index it, and run one explore call. */
+async function runFixture(fixture: string, query: string): Promise
{
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg36-'));
+ fs.cpSync(path.join(__dirname, 'fixtures', fixture), dir, { recursive: true });
+ fs.rmSync(path.join(dir, '.codegraph'), { recursive: true, force: true });
+
+ const cg = CodeGraph.initSync(dir);
+ await cg.indexAll();
+
+ const sidecar = path.join(dir, 'explore-diag.jsonl');
+ const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
+ process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
+ let response: string;
+ try {
+ response = (await new ToolHandler(cg).execute('codegraph_explore', { query }))
+ .content?.[0]?.text ?? '';
+ } finally {
+ if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
+ else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
+ }
+ const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
+ return { dir, cg, response, report: JSON.parse(written[written.length - 1]!) };
+}
+
+function teardown(run: Run | undefined): void {
+ if (!run) return;
+ run.cg.destroy();
+ if (fs.existsSync(run.dir)) fs.rmSync(run.dir, { recursive: true, force: true });
+}
+
+describe('CG-36 — a trivial cluster must not starve the answer-bearing one', () => {
+ const TARGET = 'src/pipeline/chain.ts';
+ const QUERY = 'how does a request travel from sendRequest to the socket';
+ let run: Run;
+ let target: ExploreDiagnosticReport['files'][number];
+
+ beforeAll(async () => {
+ run = await runFixture('starved-cluster-ts', QUERY);
+ target = run.report.files.find((f) => f.path === TARGET)!;
+ }, 120_000);
+
+ afterAll(() => teardown(run));
+
+ describe('fixture shape — if this rots, the gate below means nothing', () => {
+ it('renders through the cluster path, with the answer past the trivial helper', () => {
+ expect(target, `${TARGET} is not among the ranked candidates`).toBeDefined();
+ expect(target.render).toBe('clusters');
+ // The helper the entry point calls directly, and the class it does not.
+ const nodes = run.cg.getNodesInFile(TARGET);
+ const helper = nodes.find((n) => n.name === 'describeChain')!;
+ const proceed = nodes.find((n) => n.name === 'proceed')!;
+ expect(helper).toBeDefined();
+ expect(proceed).toBeDefined();
+ // Far enough apart to cluster separately at any gap threshold we ship.
+ expect(proceed.startLine - helper.endLine).toBeGreaterThan(20);
+ });
+
+ it('reserves the file the largest share, so an unspent share is a defect', () => {
+ expect(target.allowance ?? 0).toBeGreaterThan(4000);
+ const others = run.report.files.filter((f) => f.path !== TARGET);
+ for (const f of others) expect(f.allowance ?? 0).toBeLessThan(target.allowance!);
+ });
+ });
+
+ describe('the gate', () => {
+ it('spends most of the reservation it was given', () => {
+ // 28.8% on the CG-24 epic tip, 131% (its reservation plus carry-forward
+ // slack it can now actually use) with the fix. The bar is deliberately
+ // well below both so ordinary budget movement does not fail the suite.
+ expect(target.finalChars / target.allowance!).toBeGreaterThan(0.6);
+ });
+
+ it('delivers the flow the query asked about, not just the helper beside it', () => {
+ // Both ends of the in-file flow, in the cluster that used to be dropped.
+ expect(run.response).toContain('async proceed(request: PipelineRequest)');
+ expect(run.response).toContain('private async writeAndRead(request: PipelineRequest)');
+ });
+
+ it('keeps the response inside the hard ceiling', () => {
+ expect(run.report.envelope.chars).toBeLessThanOrEqual(run.report.budget.hardCeiling);
+ });
+ });
+});
+
+describe('CG-36 — a dense declaration block must not bury the query\'s methods', () => {
+ const TARGET = 'src/net/session.ts';
+ const QUERY = 'how does perform create a URLRequest and start the task';
+ let run: Run;
+ let target: ExploreDiagnosticReport['files'][number];
+
+ beforeAll(async () => {
+ run = await runFixture('dense-header-ts', QUERY);
+ target = run.report.files.find((f) => f.path === TARGET)!;
+ }, 120_000);
+
+ afterAll(() => teardown(run));
+
+ describe('fixture shape — if this rots, the gate below means nothing', () => {
+ it('has a dense low-importance header and the named methods far below it', () => {
+ expect(target, `${TARGET} is not among the ranked candidates`).toBeDefined();
+ expect(target.render).toBe('clusters');
+ const nodes = run.cg.getNodesInFile(TARGET);
+ const perform = nodes.find((n) => n.name === 'perform')!;
+ expect(perform).toBeDefined();
+ // The header block: many adjacent declarations above the first named
+ // method, which is what makes it the densest region of the file.
+ const above = nodes.filter((n) => n.endLine < perform.startLine
+ && (n.kind === 'property' || n.kind === 'field' || n.kind === 'method'));
+ expect(above.length).toBeGreaterThan(20);
+ expect(perform.startLine).toBeGreaterThan(150);
+ });
+ });
+
+ describe('the gate', () => {
+ it('delivers all three methods the query named', () => {
+ expect(run.response).toContain('async perform(url: string, method: string');
+ expect(run.response).toContain('didCreateURLRequest(request: URLRequest)');
+ expect(run.response).toContain('task(request: URLRequest, identifier: number)');
+ });
+
+ it('spends the file\'s reservation on them', () => {
+ expect(target.finalChars / target.allowance!).toBeGreaterThan(0.6);
+ });
+
+ it('keeps the response inside the hard ceiling', () => {
+ expect(run.report.envelope.chars).toBeLessThanOrEqual(run.report.budget.hardCeiling);
+ });
+ });
+});
diff --git a/__tests__/explore-declaration-only.test.ts b/__tests__/explore-declaration-only.test.ts
new file mode 100644
index 0000000..004711f
--- /dev/null
+++ b/__tests__/explore-declaration-only.test.ts
@@ -0,0 +1,207 @@
+/**
+ * Regression gate for DECLARATION-ONLY files in explore ranking (task CG-28).
+ *
+ * A file that holds nothing but type declarations — an ambient `.d.ts`, vendored
+ * typings, a `types.ts` of pure interfaces — cannot answer a FLOW question: no
+ * bodies, no call edges, no behaviour. But the identifiers it declares are
+ * exactly the generic ones a prose question uses (`Body`, `Message`,
+ * `ImageMetadata`, `ReadableStream`), so on term overlap it out-scored the
+ * implementation and took the envelope. Measured on this fixture before the fix:
+ * rank #1 and 51% of delivered source on a prose flow query.
+ *
+ * CG-25 already covers the file that STARTED this — a Wrangler
+ * `worker-configuration.d.ts`, which announces itself with a generated banner.
+ * `docs/benchmarks/explore-declaration-only-cg28.md` has that measurement; the
+ * banner alone is worth 15–46 points of envelope share. What it does not cover
+ * is a declaration file with no banner at all, which is what this fixture's
+ * `platform-shims.d.ts` is, and what the damping in `rankPenalty` addresses.
+ *
+ * Two claims, and BOTH have to hold — the counter-case is why the penalty is
+ * guarded rather than flat:
+ *
+ * 1. a prose flow query must not let a declaration-only file outrank the
+ * implementation files that answer it;
+ * 2. a query genuinely ABOUT a declared type must still reach the declaration
+ * at full weight.
+ *
+ * The suppression the issue explicitly forbids is also pinned: a damped file is
+ * still a candidate and still named in the response, so one follow-up explore
+ * fetches it.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
+
+const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'ambient-decls-ts');
+
+/** Declaration-only, hand-written, NO generated banner — the surviving gap. */
+const HANDWRITTEN_DECL = 'types/platform-shims.d.ts';
+/** Declaration-only WITH a Wrangler banner — the CG-25 control in the same run. */
+const GENERATED_DECL = 'types/worker-configuration.d.ts';
+/** Declaration-only but IMPORTED by the storage layer — must never be damped. */
+const SHARED_TYPES = 'src/storage/types.ts';
+
+/** Prose, naming no symbol — the query shape that let the original file in. */
+const FLOW_QUERY =
+ 'how does an upload request stream the file body to storage and record image metadata';
+/** Prose that DOES name a declared type — the counter-case. */
+const TYPE_QUERY = 'what does the UploadStorage interface declare for putting an object';
+
+describe('CG-28 — a declaration-only file does not outrank implementation on a flow query', () => {
+ let testDir: string;
+ let cg: CodeGraph;
+ let sidecar: string;
+
+ /** One explore call; returns its diagnostic report plus the response text. */
+ const explore = async (query: string): Promise<{ report: ExploreDiagnosticReport; text: string }> => {
+ fs.rmSync(sidecar, { force: true });
+ const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
+ process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
+ let text: string;
+ try {
+ text = (await new ToolHandler(cg).execute('codegraph_explore', { query })).content?.[0]?.text ?? '';
+ } finally {
+ if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
+ else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
+ }
+ const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
+ return { report: JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport, text };
+ };
+
+ const fileOf = (report: ExploreDiagnosticReport, p: string): ExploreDiagnosticFile | undefined =>
+ report.files.find((f) => f.path === p);
+
+ let flow: { report: ExploreDiagnosticReport; text: string };
+ let typed: { report: ExploreDiagnosticReport; text: string };
+
+ beforeAll(async () => {
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg28-'));
+ fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
+ fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
+ sidecar = path.join(testDir, 'explore-diag.jsonl');
+
+ cg = CodeGraph.initSync(testDir);
+ await cg.indexAll();
+
+ flow = await explore(FLOW_QUERY);
+ typed = await explore(TYPE_QUERY);
+ }, 120_000);
+
+ afterAll(() => {
+ if (cg) cg.destroy();
+ if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+ });
+
+ describe('fixture shape — if this rots, the gate below means nothing', () => {
+ it('holds two declaration-only files that differ only in the banner', () => {
+ for (const p of [HANDWRITTEN_DECL, GENERATED_DECL]) {
+ const nodes = cg.getNodesInFile(p).filter((n) => n.kind !== 'file' && n.kind !== 'import');
+ expect(nodes.length, `${p} declares nothing`).toBeGreaterThan(10);
+ // Every symbol type-level, nothing with a body — the structural test the
+ // penalty keys on. A `function`/`class` creeping in would silently exempt
+ // the file and make every assertion below vacuous.
+ expect(nodes.every((n) => n.kind === 'interface' || n.kind === 'type_alias'), `${p} has a non-type symbol`).toBe(true);
+ }
+ // Only one of them announces itself, so the CG-25 penalty is the ONLY
+ // difference between the two — that is what makes them comparable.
+ expect(cg.getFile(GENERATED_DECL)?.generated).toBe(true);
+ expect(cg.getFile(HANDWRITTEN_DECL)?.generated).toBeFalsy();
+ });
+
+ it('holds a pure-type module the code IMPORTS, as the safety control', () => {
+ // Identical to the ambient files on kinds and bodies; different only in
+ // that the storage layer is typed by it. This is the shape the penalty
+ // must NOT catch — a `types.ts` the codebase depends on is part of the
+ // structure of any answer about that code.
+ const nodes = cg.getNodesInFile(SHARED_TYPES).filter((n) => n.kind !== 'file' && n.kind !== 'import');
+ expect(nodes.length).toBeGreaterThan(0);
+ expect(nodes.every((n) => n.kind === 'interface' || n.kind === 'type_alias')).toBe(true);
+ expect(cg.getFile(SHARED_TYPES)?.generated).toBeFalsy();
+ });
+
+ it('holds implementation files that DO answer the flow question', () => {
+ for (const p of ['src/routes/upload.ts', 'src/storage/stream.ts', 'src/storage/metadata.ts']) {
+ expect(cg.getNodesInFile(p).some((n) => n.kind === 'function'), `${p} has no functions`).toBe(true);
+ }
+ });
+ });
+
+ describe('the gate — a prose flow query', () => {
+ it('damps the un-bannered declaration file rather than letting it rank free', () => {
+ const rec = fileOf(flow.report, HANDWRITTEN_DECL);
+ expect(rec, 'the declaration file is not even a candidate — fixture drifted').toBeDefined();
+ expect(rec!.ambientDeclaration).toBe(true);
+ expect(rec!.penalty).toBeLessThan(1);
+ });
+
+ it('does not let it outrank the implementation files', () => {
+ const decl = fileOf(flow.report, HANDWRITTEN_DECL)!;
+ const impl = flow.report.files.filter((f) => f.path.startsWith('src/') && f.finalChars > 0);
+ expect(impl.length, 'no implementation file delivered anything').toBeGreaterThanOrEqual(2);
+ // Measured before the fix: the declaration file was rank #1 with score 53
+ // against the best implementation file's 34. The bar is that at least one
+ // implementation file now ranks above it — ordinary budget movement must
+ // not fail the suite, but the inversion coming back must.
+ expect(impl.some((f) => f.rank < decl.rank), 'declaration file still ranks first').toBe(true);
+ });
+
+ it('still names it in the response, so one follow-up call fetches it', () => {
+ // The issue forbids suppression: a damped file must remain reachable.
+ expect(flow.text).toContain(HANDWRITTEN_DECL);
+ });
+
+ it('leaves the implementation files at full weight', () => {
+ for (const f of flow.report.files.filter((x) => x.path.startsWith('src/'))) {
+ expect(f.ambientDeclaration, `${f.path} was misread as an ambient declaration`).toBe(false);
+ expect(f.penalty).toBe(1);
+ }
+ });
+
+ it('does not damp a pure-type module the codebase imports', () => {
+ // The condition that keeps this narrow enough to be safe. Without it the
+ // same rule demotes `displacement-ts`'s pipeline `types.ts` — pure
+ // interfaces, but 13 inbound imports — and breaks the CG-31 gate.
+ const rec = flow.report.files.find((f) => f.path === SHARED_TYPES);
+ if (rec) {
+ expect(rec.ambientDeclaration, `${SHARED_TYPES} was flagged ambient`).toBe(false);
+ expect(rec.penalty).toBe(1);
+ }
+ // Independent of whether this query ranked it: the predicate itself must
+ // separate the two shapes.
+ const isAmbient = cg.ambientDeclarationFilePredicate([SHARED_TYPES, HANDWRITTEN_DECL]);
+ expect(isAmbient(SHARED_TYPES)).toBe(false);
+ expect(isAmbient(HANDWRITTEN_DECL)).toBe(true);
+ });
+ });
+
+ describe('the counter-case — a query that NAMES a declared type', () => {
+ it('reaches the declaration at full weight, undamped', () => {
+ const rec = fileOf(typed.report, HANDWRITTEN_DECL);
+ expect(rec, 'the named type\'s file is not a candidate').toBeDefined();
+ expect(rec!.ambientDeclaration).toBe(true);
+ // Detected as declaration-only, but EXEMPT — the query asked for it.
+ expect(rec!.penalty).toBe(1);
+ });
+
+ it('ranks it first and delivers its source', () => {
+ const rec = fileOf(typed.report, HANDWRITTEN_DECL)!;
+ expect(rec.rank).toBe(1);
+ expect(rec.finalChars).toBeGreaterThan(0);
+ });
+ });
+
+ describe('the two penalties do not stack', () => {
+ it('charges a generated declaration file once, at the stronger rate', () => {
+ // A file that is BOTH generated and declaration-only has ONE property two
+ // signals happen to see. Penalising twice (0.3 * 0.5 = 0.15) is how a file
+ // gets cliffed out of answers where it is genuinely relevant.
+ const rec = flow.report.files.find((f) => f.generated && f.ambientDeclaration);
+ if (!rec) return; // not a candidate for this query — nothing to assert
+ expect(rec.penalty).toBeGreaterThanOrEqual(0.3);
+ });
+ });
+});
diff --git a/__tests__/explore-displacement-guard.test.ts b/__tests__/explore-displacement-guard.test.ts
new file mode 100644
index 0000000..716bc97
--- /dev/null
+++ b/__tests__/explore-displacement-guard.test.ts
@@ -0,0 +1,245 @@
+/**
+ * Regression fixture for CG-31 — a clustered render may not spend a reservation
+ * still owed to a file the loop has not reached.
+ *
+ * The allocator hands every admitted file a reservation (CG-12), and the render
+ * loop then walks the files in rank order. Carry-forward slack lets a file spend
+ * what the files ABOVE it left on the table, which is right; what was missing is
+ * the other half — nothing was held back for the files BELOW it. The whole-file
+ * BUY arm has always refused that trade (`owedBelow`, `tools.ts`); the cluster
+ * path had no equivalent, so `fileBudget`/`SPINE_CEILING` read what was left
+ * before the hard ceiling rather than what was still promised, and the first
+ * oversize file could take the response.
+ *
+ * `__tests__/fixtures/displacement-ts/` reproduces it. Four pipeline stages
+ * compete for one envelope; the first, `ingest.ts`, is a single ~20K function —
+ * one cluster member far bigger than any reservation it can earn — so it takes
+ * the bounded overshoot CG-30 left it. The fixture is padded to >500 indexed
+ * files on purpose: the displacement only exists on the 24K tier, where the
+ * reservations plus the response preamble genuinely saturate the hard ceiling.
+ *
+ * Measured against the pre-fix build (CG-30 landed, CG-31 not):
+ *
+ * ingest.ts 9,301 chars emitted on a 6,289 spendable — then dropped whole
+ * by the final ceiling, so it cost the response and delivered 0
+ * types.ts skipped `budget-whole-file`
+ * sink.ts skipped `budget-whole-file`
+ * delivered 3 of 6 admitted files, 14,908-char envelope
+ *
+ * With the guard: 6 of 6, 22,066-char envelope, and `ingest.ts` bounded to the
+ * 4,913 that were actually still free.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
+import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
+
+const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'displacement-ts');
+
+/**
+ * Padding modules, written into the temp copy rather than checked in. The
+ * output tier is chosen by INDEXED FILE COUNT, and the displacement this test
+ * pins only exists at >=500 files (24K envelope against a 24.4K render ceiling
+ * that also has to hold the response preamble). Below that the ceiling has
+ * enough slack to absorb an overshoot and the bug is invisible.
+ */
+const FILLER_FILES = 520;
+
+/** A symbol bag spanning all four stages — they compete for one envelope. */
+const QUERY = 'ingestRecords normalizeRecords enrichRecords publishRecords';
+/** One symbol, one file — the concentration case the guard must not flatten. */
+const PRECISE_QUERY = 'ingestRecords';
+
+/** The giant: one ~20K function, the file that used to take the response. */
+const GIANT = 'src/pipeline/ingest.ts';
+/** Ranked below the giant and dropped by it pre-fix. */
+const STARVED = ['src/pipeline/types.ts', 'src/pipeline/sink.ts'];
+
+interface Probe {
+ response: string;
+ report: ExploreDiagnosticReport;
+ bytes: Map;
+}
+
+describe('CG-31 — the cluster path holds back what is still owed below it', () => {
+ let testDir: string;
+ let cg: CodeGraph;
+ let spread: Probe;
+ let precise: Probe;
+
+ const fileOf = (probe: Probe, p: string): ExploreDiagnosticFile => {
+ const rec = probe.report.files.find((f) => f.path === p);
+ if (!rec) throw new Error(`${p} absent from the diagnostic report`);
+ return rec;
+ };
+ /** Admitted = the allocator reserved bytes for it. */
+ const admitted = (probe: Probe): ExploreDiagnosticFile[] =>
+ probe.report.files.filter((f) => (f.allowance ?? 0) > 0);
+
+ beforeAll(async () => {
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg31-'));
+ fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
+ fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
+
+ const filler = path.join(testDir, 'src', 'generated');
+ fs.mkdirSync(filler, { recursive: true });
+ for (let i = 0; i < FILLER_FILES; i++) {
+ // Deterministic, unrelated to the query — these pad the file count, they
+ // must never rank.
+ fs.writeFileSync(
+ path.join(filler, `unit${i}.ts`),
+ `export const seed${i} = ${i};\n`
+ + `export function widget${i}(n: number): number {\n return n * ${i + 1} + seed${i};\n}\n`,
+ );
+ }
+
+ cg = CodeGraph.initSync(testDir);
+ await cg.indexAll();
+
+ // The per-file bounds are only observable through the diagnostic sidecar.
+ const sidecar = path.join(testDir, 'explore-diag.jsonl');
+ const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
+ process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
+ const run = async (handler: ToolHandler, query: string): Promise => {
+ const result = await handler.execute('codegraph_explore', { query });
+ const response = result.content?.[0]?.text ?? '';
+ const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
+ return {
+ response,
+ report: JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport,
+ bytes: attributeSourceBytes(response),
+ };
+ };
+ try {
+ const handler = new ToolHandler(cg);
+ spread = await run(handler, QUERY);
+ precise = await run(handler, PRECISE_QUERY);
+ } finally {
+ if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
+ else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
+ }
+ }, 180_000);
+
+ afterAll(() => {
+ if (cg) cg.destroy();
+ if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+ });
+
+ // ── Fixture shape — if these rot, the gate below means nothing ─────────────
+
+ describe('fixture shape', () => {
+ it('sits on the 24K tier, where the reservations saturate the ceiling', () => {
+ expect(cg.getStats().fileCount).toBeGreaterThanOrEqual(500);
+ expect(spread.report.budget.maxOutputChars).toBe(24000);
+ });
+
+ it('admits every stage file, so there is something to displace', () => {
+ const paths = admitted(spread).map((f) => f.path);
+ expect(paths).toContain(GIANT);
+ for (const p of STARVED) expect(paths).toContain(p);
+ expect(paths.length).toBeGreaterThanOrEqual(5);
+ });
+
+ it('renders the giant through the CLUSTER path, over its reservation', () => {
+ const rec = fileOf(spread, GIANT);
+ expect(rec.render).toBe('clusters');
+ // One member bigger than anything it can earn beside its siblings — the
+ // shape that makes the bounded overshoot fire at all.
+ const source = fs.readFileSync(path.join(testDir, GIANT), 'utf-8');
+ expect(source.length).toBeGreaterThan((rec.spendable ?? 0) * 2);
+ // And the guard actually bit — a vacuous pass here would hide a
+ // regression. Measured against the bounded overshoot a cluster's top
+ // member may otherwise take (1.5x, CG-30), which is what it refused.
+ expect(rec.funded).not.toBeNull();
+ expect(rec.funded!).toBeLessThan(Math.round(rec.spendable! * 1.5));
+ });
+ });
+
+ // ── The gate ──────────────────────────────────────────────────────────────
+
+ describe('displacement refusal', () => {
+ it('CG-31 GATE: no clustered file emits past what was still free to spend', () => {
+ for (const probe of [spread, precise]) {
+ const over = probe.report.files
+ .filter((f) => f.render === 'clusters' && f.funded !== null)
+ // +1 for the render loop's own rounding on the windowed cut.
+ .filter((f) => f.emittedChars > f.funded! + 1)
+ .map((f) => `${f.path}: ${f.emittedChars} of ${f.funded}`);
+ expect(over).toEqual([]);
+ }
+ });
+
+ it('CG-31 GATE: every admitted file below the top one is delivered', () => {
+ // Pre-fix: 3 of 6 — `ingest.ts` overshot, was itself cut by the final
+ // ceiling, and took `types.ts` + `sink.ts` down with it.
+ for (const rec of admitted(spread)) {
+ expect(rec.skipped, `${rec.path} skipped`).toBeNull();
+ expect(spread.bytes.get(rec.path) ?? 0, `${rec.path} bytes`).toBeGreaterThan(0);
+ }
+ for (const p of STARVED) expect(spread.bytes.get(p) ?? 0).toBeGreaterThan(0);
+ });
+
+ it('the guard is symmetric — it is about ORDER, not rank', () => {
+ // Nothing here protects rank #1 specifically: the LAST admitted file, the
+ // only one with no reservation owed below it, is delivered too.
+ const files = admitted(spread);
+ const last = files[files.length - 1]!;
+ expect(last.skipped).toBeNull();
+ expect(spread.bytes.get(last.path) ?? 0).toBeGreaterThan(0);
+ // And the last file is never itself cut by the guard — nothing is owed
+ // below it, so `funded` may not sit under its own reservation.
+ expect(last.funded!).toBeGreaterThanOrEqual(Math.min(last.allowance!, last.emittedChars));
+ });
+
+ it('a kept promise is not a displacement — no file is cut below its reservation', () => {
+ for (const probe of [spread, precise]) {
+ for (const rec of admitted(probe)) {
+ if (rec.funded === null) continue;
+ expect(rec.funded, rec.path).toBeGreaterThanOrEqual(
+ Math.min(rec.allowance!, rec.emittedChars));
+ }
+ }
+ });
+
+ it('nothing is lost to the hard ceiling — the epilogue is cut before a section', () => {
+ // A section thrown away by the final truncation is the same starvation
+ // arriving after the guard has done its work: the bytes were held back
+ // for that file and then nobody received them.
+ for (const probe of [spread, precise]) {
+ expect(probe.report.files.filter((f) => f.render === 'dropped')).toEqual([]);
+ }
+ });
+
+ it('keeps the response inside the hard ceiling', () => {
+ for (const probe of [spread, precise]) {
+ expect(probe.report.envelope.chars).toBeLessThanOrEqual(probe.report.budget.hardCeiling);
+ }
+ });
+ });
+
+ // ── The thing the guard must NOT become ───────────────────────────────────
+
+ describe('concentration survives', () => {
+ it('a precise symbol query still puts the most source in the named file', () => {
+ const mine = precise.bytes.get(GIANT) ?? 0;
+ const others = [...precise.bytes.entries()].filter(([p]) => p !== GIANT);
+ expect(mine).toBeGreaterThan(0);
+ for (const [p, n] of others) {
+ expect(mine, `${GIANT} vs ${p}`).toBeGreaterThan(n);
+ }
+ // Not a forced even split: the named file takes a clear plurality.
+ const total = [...precise.bytes.values()].reduce((s, n) => s + n, 0);
+ expect(mine / total).toBeGreaterThan(1 / precise.bytes.size);
+ });
+
+ it('the named file still outspends what it would get from an even split', () => {
+ const rec = fileOf(precise, GIANT);
+ const even = precise.report.budget.maxOutputChars / admitted(precise).length;
+ expect(rec.emittedChars).toBeGreaterThan(even);
+ });
+ });
+});
diff --git a/__tests__/explore-factory-closure.test.ts b/__tests__/explore-factory-closure.test.ts
new file mode 100644
index 0000000..d0b0838
--- /dev/null
+++ b/__tests__/explore-factory-closure.test.ts
@@ -0,0 +1,157 @@
+/**
+ * Regression gate for the FACTORY-CLOSURE file shape (task CG-27).
+ *
+ * A `createFoo()` that returns an object of closures spans almost all of its
+ * file, so its indexed range is an ENVELOPE around every symbol the query
+ * actually wants. Svelte 5 rune stores, React custom-hook modules, IIFE
+ * module-pattern JS and Zustand's `create((set, get) => ({ … }))` are all
+ * written this way, so it is a shape rather than a one-repo quirk.
+ *
+ * CG-27 asked whether the >50%-of-file envelope drop — which fires for `class`,
+ * `struct`, `interface` and friends but not for `function`/`method` — should be
+ * extended to cover it. **Measured, it should not**, and the issue was closed as
+ * obsolete: `docs/benchmarks/explore-factory-closure-cg27.md` has the numbers.
+ * Two independent mechanisms already absorb the shape:
+ *
+ * - `shrinkCluster` orders members by (importance desc, SIZE ASC) and refuses
+ * any member that overruns the cap once something is kept, so a file-spanning
+ * member is only ever selected when it is the sole member of the top
+ * importance tier;
+ * - when it IS selected, CG-30 windows it on whole lines rather than emitting
+ * it whole, so the file still delivers bounded, readable source.
+ *
+ * Dropping the range instead SPLITS the file into several clusters, and only the
+ * first-chosen cluster may be shrunk — measured, a trivial 7-line cluster won the
+ * density tiebreak and the answer-bearing cluster was dropped whole, taking the
+ * rank-#1 file from 7,539 chars and 7 of 11 inner definitions to 397 and none.
+ *
+ * So this file pins the OUTCOME, not the mechanism: whatever future work does to
+ * clustering, a factory-closure file must keep delivering the closures inside it
+ * — that is what stops the agent Reading the file back.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+import type { ExploreDiagnosticReport } from '../src/mcp/explore-diagnostics';
+
+const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'factory-closure-ts');
+
+/** The factory file, and the closure factory whose body is nearly all of it. */
+const TARGET = 'src/stores/dashboard-store.ts';
+const FACTORY = 'createDashboardStore';
+/** Prose the way a newcomer asks it, naming two of the closures inside. */
+const QUERY = 'how does the dashboard store refresh its metrics and apply a filter';
+
+describe('CG-27 — a factory-closure file delivers the closures inside it', () => {
+ let testDir: string;
+ let cg: CodeGraph;
+ let response: string;
+ let report: ExploreDiagnosticReport;
+ /** Source lines of TARGET the response actually carried. */
+ let delivered: Set;
+ let sourceLines: string[];
+
+ beforeAll(async () => {
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg27-'));
+ fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
+ fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
+
+ cg = CodeGraph.initSync(testDir);
+ await cg.indexAll();
+
+ const sidecar = path.join(testDir, 'explore-diag.jsonl');
+ const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
+ process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
+ try {
+ response = (await new ToolHandler(cg).execute('codegraph_explore', { query: QUERY }))
+ .content?.[0]?.text ?? '';
+ } finally {
+ if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
+ else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
+ }
+ const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
+ report = JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport;
+
+ // A line counts as delivered only when the response numbers it AND the text
+ // matches that source line — a line number quoted in prose must not count.
+ sourceLines = fs.readFileSync(path.join(testDir, TARGET), 'utf-8').split('\n');
+ delivered = new Set();
+ for (const line of response.split('\n')) {
+ const m = /^(\d+)\t(.*)$/.exec(line);
+ if (!m) continue;
+ const n = Number(m[1]);
+ if (n >= 1 && n <= sourceLines.length && sourceLines[n - 1] === m[2]) delivered.add(n);
+ }
+ }, 120_000);
+
+ afterAll(() => {
+ if (cg) cg.destroy();
+ if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+ });
+
+ /** The closures defined inside the factory, straight from the index. */
+ const innerClosures = () => {
+ const nodes = cg.getNodesInFile(TARGET);
+ const factory = nodes.find((n) => n.name === FACTORY)!;
+ return nodes.filter((n) => (n.kind === 'function' || n.kind === 'method')
+ && n.name !== FACTORY
+ && n.startLine > factory.startLine && n.endLine <= factory.endLine);
+ };
+
+ describe('fixture shape — if this rots, the gate below means nothing', () => {
+ it('holds one symbol spanning most of the file, with closures inside it', () => {
+ const factory = cg.getNodesInFile(TARGET).find((n) => n.name === FACTORY);
+ expect(factory, `${TARGET} has no ${FACTORY} node`).toBeDefined();
+ // The envelope condition the >50% drop tests for — and `function`, the kind
+ // that drop does not cover.
+ expect(factory!.kind).toBe('function');
+ expect(factory!.endLine - factory!.startLine + 1)
+ .toBeGreaterThan(sourceLines.length * 0.5);
+ expect(innerClosures().length).toBeGreaterThanOrEqual(8);
+ });
+
+ it('is too long to ship whole, so it renders through the cluster path', () => {
+ // Past WHOLE_FILE_MAX_LINES (220 for a non-central file): the whole-file
+ // grace and buy arms cannot claim it, so the envelope actually matters.
+ expect(sourceLines.length).toBeGreaterThan(220);
+ expect(report.files.find((f) => f.path === TARGET)?.render).toBe('clusters');
+ });
+ });
+
+ describe('the gate', () => {
+ it('delivers the closures the query named, not just the factory head', () => {
+ const inner = innerClosures();
+ for (const name of ['refreshMetrics', 'applyFilter']) {
+ const node = inner.find((n) => n.name === name)!;
+ expect(node, `${name} is not an inner closure any more`).toBeDefined();
+ expect(delivered.has(node.startLine), `${name} definition line not delivered`).toBe(true);
+ }
+ });
+
+ it('delivers most of the closures, spread across the file', () => {
+ const inner = innerClosures();
+ const hit = inner.filter((n) => delivered.has(n.startLine));
+ // Measured on the `feature/CG-24` tip: 7 of 11. The bar is half, so ordinary
+ // budget movement does not fail the suite, but losing the closures does.
+ expect(hit.length).toBeGreaterThanOrEqual(Math.ceil(inner.length / 2));
+ // Not one contiguous head window off the top of the factory: the whole
+ // point is that selection reaches symbols deep in the body.
+ const last = inner[inner.length - 1]!;
+ const deepest = Math.max(...hit.map((n) => n.startLine));
+ expect(deepest).toBeGreaterThan((last.startLine + inner[0]!.startLine) / 2);
+ });
+
+ it('never renders an empty section for the file', () => {
+ const rec = report.files.find((f) => f.path === TARGET)!;
+ expect(rec.emittedChars).toBeGreaterThan(0);
+ expect(delivered.size).toBeGreaterThan(20);
+ });
+
+ it('keeps the response inside the hard ceiling', () => {
+ expect(report.envelope.chars).toBeLessThanOrEqual(report.budget.hardCeiling);
+ });
+ });
+});
diff --git a/__tests__/explore-named-symbol-render.test.ts b/__tests__/explore-named-symbol-render.test.ts
new file mode 100644
index 0000000..41b9306
--- /dev/null
+++ b/__tests__/explore-named-symbol-render.test.ts
@@ -0,0 +1,199 @@
+/**
+ * Standing gate for THE GUARANTEE (task CG-38): if the agent names a symbol and
+ * that symbol's file is admitted to the response, the symbol's DEFINITION renders.
+ *
+ * This is the measurement the CG-24 epic never had. Its probes all score the
+ * response in aggregate — envelope share, per-file spend, source totals, file
+ * counts — and every one of them is green on a response that returns 25K of
+ * source from the right file and still omits the function the agent asked for by
+ * name. That is what CG-38 was: on a 1,414-line Svelte store, `queueMessage`
+ * (L1087) and `flushQueuedMessages` (L1102) never rendered even though their file
+ * won rank #1 with 67% of the envelope; the agent got the same-stem
+ * `QueuedMessage` INTERFACE at L70 and had to Read the file to find the
+ * functions. Longstanding, not an epic regression — the controlled bisect (index
+ * held fixed, engine varied across every epic merge point) found it at every
+ * build including pre-epic.
+ *
+ * Two independent causes, and the fixture below fails on either:
+ *
+ * 1. `buildFlowFromNamedSymbols` returned EMPTY — throwing away the NAMED-SYMBOL
+ * IDENTITY along with the narrative — whenever the named symbols happened not
+ * to form a call chain. Two sibling closures in one factory produce no chain,
+ * no synthesized hop and no dispatch boundary, so both defs lost the
+ * importance-9 rank that the named-def injection exists to give them.
+ * 2. The ceiling trim cut in SOURCE ORDER, so whatever survived the shrink at
+ * the END of a large file was always the first thing dropped.
+ *
+ * The fixture mirrors the reported file's geometry deliberately: a decoy
+ * same-stem interface at L70, a factory closure at L104 spanning ~92% of the file
+ * (so every symbol merges into ONE cluster), the target functions past L1000, and
+ * a 2,500-line generated `.d.ts` for the ranker to penalise.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+
+const FIXTURE = 'tail-render-ts';
+const TARGET = 'src/lib/session-store.ts';
+
+let dir: string;
+let cg: CodeGraph;
+
+/** Every `\t` line number the response actually sent. */
+function renderedLines(response: string): Set {
+ const out = new Set();
+ for (const m of response.matchAll(/^(\d+)\t/gm)) out.add(Number(m[1]));
+ return out;
+}
+
+async function explore(query: string): Promise {
+ const res = await new ToolHandler(cg).execute('codegraph_explore', { query });
+ return res.content?.[0]?.text ?? '';
+}
+
+function defLineOf(name: string): number {
+ const node = cg.getNodesByName(name).find((n) => n.filePath === TARGET && n.startLine > 0);
+ expect(node, `${name} is not indexed in ${TARGET}`).toBeDefined();
+ return node!.startLine;
+}
+
+beforeAll(async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg38-'));
+ fs.cpSync(path.join(__dirname, 'fixtures', FIXTURE), dir, { recursive: true });
+ fs.rmSync(path.join(dir, '.codegraph'), { recursive: true, force: true });
+ cg = CodeGraph.initSync(dir);
+ await cg.indexAll();
+}, 180_000);
+
+afterAll(() => {
+ cg?.destroy();
+ if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
+});
+
+describe('CG-38 fixture shape — if this rots, the gate below means nothing', () => {
+ it('puts the target functions past L1000 of a ~1,400-line file', () => {
+ const lines = fs.readFileSync(path.join(dir, TARGET), 'utf-8').split('\n');
+ expect(lines.length).toBeGreaterThan(1300);
+ expect(defLineOf('queueMessage')).toBeGreaterThan(1000);
+ expect(defLineOf('flushQueuedMessages')).toBeGreaterThan(1000);
+ });
+
+ it('wraps them in a closure spanning most of the file, so they all cluster as one', () => {
+ const lines = fs.readFileSync(path.join(dir, TARGET), 'utf-8').split('\n');
+ const factory = cg.getNodesByName('createSessionStore')
+ .find((n) => n.filePath === TARGET)!;
+ expect(factory).toBeDefined();
+ expect(factory.endLine - factory.startLine + 1).toBeGreaterThan(lines.length * 0.5);
+ });
+
+ it('carries the same-stem decoy near the top', () => {
+ const decoy = cg.getNodesByName('QueuedMessage').find((n) => n.filePath === TARGET)!;
+ expect(decoy).toBeDefined();
+ expect(decoy.kind).toBe('interface');
+ expect(decoy.startLine).toBeLessThan(100);
+ });
+
+ it('carries a generated declaration file for the ranker to penalise', () => {
+ const dts = path.join(dir, 'types/worker-configuration.d.ts');
+ expect(fs.existsSync(dts)).toBe(true);
+ expect(fs.readFileSync(dts, 'utf-8').split('\n').length).toBeGreaterThan(2000);
+ });
+
+ it('neither target calls the other — that absence is what produced no flow', () => {
+ const queue = cg.getNodesByName('queueMessage').find((n) => n.filePath === TARGET)!;
+ const flush = cg.getNodesByName('flushQueuedMessages').find((n) => n.filePath === TARGET)!;
+ const between = [...cg.getCallees(queue.id), ...cg.getCallees(flush.id)]
+ .filter(({ node }) => node.id === queue.id || node.id === flush.id);
+ expect(between).toHaveLength(0);
+ });
+});
+
+describe('CG-38 — an agent-named symbol renders its definition', () => {
+ /**
+ * Both reported query shapes. They fail for different reasons — the symbol bag
+ * never built a flow at all, the prose question built one and then lost the
+ * tail to the ceiling trim — so a fix for one does not imply the other.
+ */
+ const CASES: Array<{ shape: string; query: string; symbols: string[] }> = [
+ {
+ shape: 'symbol bag',
+ query: 'queueMessage flushQueuedMessages',
+ symbols: ['queueMessage', 'flushQueuedMessages'],
+ },
+ {
+ shape: 'prose question',
+ query: 'how does queueMessage hand its entries to flushQueuedMessages',
+ symbols: ['queueMessage', 'flushQueuedMessages'],
+ },
+ {
+ shape: 'three siblings, with the decoy interface competing',
+ query: 'explain queueMessage, removeQueuedMessage and flushQueuedMessages',
+ symbols: ['queueMessage', 'removeQueuedMessage', 'flushQueuedMessages'],
+ },
+ ];
+
+ for (const { shape, query, symbols } of CASES) {
+ it(`renders every named definition — ${shape}`, async () => {
+ const response = await explore(query);
+ const lines = renderedLines(response);
+ for (const name of symbols) {
+ const line = defLineOf(name);
+ // The NAME alone proves nothing: it appears in the section header's
+ // symbol list and at call sites whether or not the body was sent. Only
+ // the definition LINE being among the rendered lines counts.
+ expect(lines.has(line), `${name} (${TARGET}:${line}) did not render for "${query}"`)
+ .toBe(true);
+ }
+ }, 120_000);
+ }
+
+ it('never steers the agent to Read', async () => {
+ const response = await explore('queueMessage flushQueuedMessages');
+ expect(response).not.toMatch(/\buse Read\b|\bRead the file\b/i);
+ }, 120_000);
+});
+
+describe('CG-38 — a penalty on one file cannot shrink an unrelated file\'s render', () => {
+ /**
+ * The issue's sharpest lead: on an index where the generated `.d.ts` was NOT
+ * flagged, the target file rendered ~581 lines including both symbols; on an
+ * index where it WAS flagged, the same engine rendered 12. `rankPenalty` scales
+ * `fileGraphScore`, which moves the relevance gate (6% of max) and so reshuffles
+ * the admitted set — a demotion of one file must not cost an unrelated
+ * top-ranked file its source.
+ *
+ * Flipping `files.generated` on that one row holds the INDEX constant and
+ * attributes any delta to the ranker alone (the CG-25 method).
+ */
+ const DTS = 'types/worker-configuration.d.ts';
+ const QUERY = 'queueMessage flushQueuedMessages';
+
+ it('renders the same named definitions with the .d.ts flagged and unflagged', async () => {
+ const setGenerated = (value: number) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const db = (cg as any).db?.getDatabase?.() ?? (cg as any).db?.db;
+ db.prepare('UPDATE files SET generated = ? WHERE path = ?').run(value, DTS);
+ };
+ const linesFor = async () => renderedLines(await explore(QUERY));
+
+ const flagged = await linesFor();
+ setGenerated(0);
+ try {
+ const unflagged = await linesFor();
+ for (const name of ['queueMessage', 'flushQueuedMessages']) {
+ const line = defLineOf(name);
+ expect(flagged.has(line), `${name} missing with the .d.ts FLAGGED`).toBe(true);
+ expect(unflagged.has(line), `${name} missing with the .d.ts UNFLAGGED`).toBe(true);
+ }
+ // The guarantee is about the named defs, not byte equality — the penalty is
+ // supposed to move bytes around. What it must never do is cost the
+ // top-ranked file the source the agent asked for.
+ expect(unflagged.size).toBeGreaterThan(0);
+ } finally {
+ setGenerated(1);
+ }
+ }, 180_000);
+});
diff --git a/__tests__/explore-oversize-member.test.ts b/__tests__/explore-oversize-member.test.ts
new file mode 100644
index 0000000..019d1ff
--- /dev/null
+++ b/__tests__/explore-oversize-member.test.ts
@@ -0,0 +1,179 @@
+/**
+ * Regression fixture for CG-30 — a cluster's top member may not overshoot the
+ * file's budget without bound.
+ *
+ * `shrinkCluster` keeps the highest-importance member of an oversize cluster
+ * WHOLE, deliberately: an empty file section sends the agent to Read, which is
+ * the outcome explore exists to prevent. What it lacked was a bound. On the
+ * originating repo one file emitted 22,376 chars against a 9,181-char
+ * reservation — 2.44x — past both the per-file budget and the spine ceiling,
+ * because its top member alone was that big. The overshoot is what collapses
+ * `headroom` for every file ranked below it (CG-31), and it has a second face:
+ * a member too big for the whole response ceiling makes the file drop out
+ * entirely rather than render short.
+ *
+ * `__tests__/fixtures/oversize-member-ts/` reproduces both permanently. Three
+ * report builders compete for one envelope, each a single long function far
+ * bigger than any reservation it can earn beside its siblings. Measured against
+ * the pre-fix build, this fixture produced:
+ *
+ * monthly.ts 12,391 chars emitted on a 3,334 budget (3.7x)
+ * quarterly.ts dropped entirely — no headroom left (the CG-31 half)
+ *
+ * The gate below is that both are now bounded AND delivered: the bound cuts the
+ * overshoot, and cutting the overshoot is what buys back the starved file.
+ *
+ * Measured against `spendable`, not `reserved`: the render paths bound
+ * themselves by the reservation PLUS whatever slack the files above left on the
+ * table, so a file legitimately spending inherited slack is not an overshoot.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
+import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
+
+const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'oversize-member-ts');
+
+/** A symbol bag spanning the three builders — the sibling files compete. */
+const QUERY = 'buildMonthlyReport buildWeeklyReport buildQuarterlyReport formatReportRow persistReport';
+
+/** The giant: one ~24K function, far past the whole-response ceiling. */
+const GIANT = 'src/report/monthly.ts';
+/** Mid-size: one ~11K function — the file the giant's overshoot used to starve. */
+const STARVED = 'src/report/quarterly.ts';
+
+/** The bound: 1.5x, the same multiple the spine ceiling already draws. */
+const OVERSHOOT_FACTOR = 1.5;
+
+describe('CG-30 — an oversize cluster member is bounded, not unbounded', () => {
+ let testDir: string;
+ let cg: CodeGraph;
+ let response: string;
+ let report: ExploreDiagnosticReport;
+ let bytes: Map;
+
+ const fileOf = (p: string): ExploreDiagnosticFile => {
+ const rec = report.files.find((f) => f.path === p);
+ if (!rec) throw new Error(`${p} absent from the diagnostic report`);
+ return rec;
+ };
+ /** What the render paths actually bound themselves by. */
+ const budgetOf = (rec: ExploreDiagnosticFile): number => rec.spendable ?? rec.allowance ?? 0;
+
+ beforeAll(async () => {
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg30-'));
+ fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
+ fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
+
+ cg = CodeGraph.initSync(testDir);
+ await cg.indexAll();
+
+ // The per-file budget is only observable through the diagnostic sidecar, and
+ // the whole gate is "emitted vs what the file was allowed to spend".
+ const sidecar = path.join(testDir, 'explore-diag.jsonl');
+ const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
+ process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
+ try {
+ const handler = new ToolHandler(cg);
+ const result = await handler.execute('codegraph_explore', { query: QUERY });
+ response = result.content?.[0]?.text ?? '';
+ } finally {
+ if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
+ else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
+ }
+ const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
+ report = JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport;
+ bytes = attributeSourceBytes(response);
+ }, 120_000);
+
+ afterAll(() => {
+ if (cg) cg.destroy();
+ if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+ });
+
+ // ── Fixture shape — if these rot, the gate below means nothing ─────────────
+
+ describe('fixture shape', () => {
+ it('holds single members far bigger than any budget they can earn', () => {
+ for (const file of [GIANT, STARVED]) {
+ const source = fs.readFileSync(path.join(testDir, file), 'utf-8');
+ const top = cg.getNodesInFile(file)
+ .filter((n) => n.kind === 'function')
+ .sort((a, b) => (b.endLine - b.startLine) - (a.endLine - a.startLine))[0];
+ expect(top, `${file} has no function node`).toBeDefined();
+ // One symbol, most of the file — the "top member alone is oversize" shape.
+ expect(top!.endLine - top!.startLine).toBeGreaterThan(180);
+ expect(source.length).toBeGreaterThan(budgetOf(fileOf(file)) * 2);
+ }
+ });
+
+ it('is too long to ship whole, so both render through the cluster path', () => {
+ for (const file of [GIANT, STARVED]) {
+ const lineCount = fs.readFileSync(path.join(testDir, file), 'utf-8').split('\n').length;
+ // Past WHOLE_FILE_MAX_LINES (220 for a non-central file), so the
+ // whole-file paths — grace and buy — cannot claim it.
+ expect(lineCount, file).toBeGreaterThan(220);
+ expect(fileOf(file).render, file).toBe('clusters');
+ }
+ });
+ });
+
+ // ── The gate ──────────────────────────────────────────────────────────────
+
+ describe('bounded overshoot', () => {
+ it('CG-30 GATE: the giant no longer emits a multiple of its budget', () => {
+ const rec = fileOf(GIANT);
+ // Pre-fix this file emitted 12,391 on a 3,334 budget (3.7x).
+ expect(rec.emittedChars).toBeLessThanOrEqual(
+ Math.round(budgetOf(rec) * OVERSHOOT_FACTOR) + 1);
+ });
+
+ it('CG-30 GATE: no clustered file emits past 1.5x what it may spend', () => {
+ const over = report.files
+ .filter((f) => f.render === 'clusters' && budgetOf(f) > 0)
+ .filter((f) => f.emittedChars > Math.round(budgetOf(f) * OVERSHOOT_FACTOR) + 1)
+ .map((f) => `${f.path}: ${f.emittedChars} of ${budgetOf(f)}`);
+ expect(over).toEqual([]);
+ });
+
+ it('CG-31: the file the overshoot used to starve is delivered', () => {
+ // Pre-fix: dropped with skip reason `budget-clusters` — the giant above it
+ // had already spent the headroom this file needed.
+ expect(fileOf(STARVED).skipped).toBeNull();
+ expect(bytes.get(STARVED) ?? 0).toBeGreaterThan(0);
+ });
+
+ it('never emits an empty section — the invariant the old rule protected', () => {
+ for (const rec of report.files) {
+ if (rec.render !== 'clusters') continue;
+ expect(rec.emittedChars, rec.path).toBeGreaterThan(0);
+ }
+ // And the windowed file still leads with the symbol the query named.
+ expect(response).toContain('export function buildMonthlyReport');
+ });
+
+ it('cuts on whole lines — a body is never sliced mid-line', () => {
+ const source = fs.readFileSync(path.join(testDir, GIANT), 'utf-8').split('\n');
+ const numbered = response
+ .split('\n')
+ .map((l) => /^(\d+)\t(.*)$/.exec(l))
+ .filter((m): m is RegExpExecArray => m !== null)
+ .filter((m) => Number(m[1]) >= 1 && Number(m[1]) <= source.length);
+ const matching = numbered.filter((m) => source[Number(m[1]) - 1] === m[2]);
+ // Every line the response numbers for this file is that whole source line.
+ expect(matching.length).toBeGreaterThan(20);
+ });
+
+ it('reports the cut rather than presenting a window as the whole file', () => {
+ expect(fileOf(GIANT).clipped).toBe(true);
+ });
+
+ it('keeps the response inside the hard ceiling', () => {
+ expect(report.envelope.chars).toBeLessThanOrEqual(report.budget.hardCeiling);
+ });
+ });
+});
diff --git a/__tests__/explore-reservation-invariant.test.ts b/__tests__/explore-reservation-invariant.test.ts
new file mode 100644
index 0000000..4b23df3
--- /dev/null
+++ b/__tests__/explore-reservation-invariant.test.ts
@@ -0,0 +1,265 @@
+/**
+ * Regression fixture for CG-26 — the end-to-end reservation invariant.
+ *
+ * Every admitted file receives at least its reservation before any file draws
+ * on carry-forward slack.
+ *
+ * CG-30 bounded how far an oversize cluster member may overshoot and CG-31 gave
+ * the cluster path a displacement guard. This pins the invariant they jointly
+ * satisfy across EVERY render path — cluster, whole-file grace, whole-file BUY —
+ * and in BOTH directions: the top-ranked file when the files below it overspend,
+ * and an admitted lower-ranked file when the top one does.
+ *
+ * Two things CG-26 fixed are pinned here because nothing else can see them:
+ *
+ * - The whole-file arms were fit-tested against raw room before the ceiling,
+ * never against what was still owed below. A grace-sized file could take a
+ * pending file's reservation on its way to the ceiling; okhttp's
+ * `CallServerInterceptor.kt` shipped 8,499 chars on a 5,964 funded ceiling
+ * and the rank-6 file below it delivered nothing.
+ * - Every section was charged a flat 200 chars of overhead while a real header
+ * runs 300–500. The loop believed it had room it did not have (okhttp
+ * rendered 26,601 chars against a 24,400 ceiling), so the final truncation
+ * threw a fully-rendered section away — the same starvation, arriving after
+ * the guard had done its work.
+ *
+ * Shares the `displacement-ts` fixture: four pipeline stages competing for one
+ * envelope, the first a single ~20K function, padded past 500 indexed files so
+ * the response sits on the 24K tier where reservations genuinely saturate the
+ * ceiling.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
+import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
+
+const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'displacement-ts');
+const FILLER_FILES = 520;
+
+/** The giant: one ~20K function. Ranks #1 under the spread query. */
+const GIANT = 'src/pipeline/ingest.ts';
+
+/**
+ * Three shapes, so the invariant is tested from both sides:
+ * spread — every stage named; the giant ranks #1 and overspends downwards.
+ * tail — the stages BELOW the giant named; something small ranks #1 while
+ * the giant competes from underneath. This is the direction CG-31's
+ * fixture could not reach.
+ * precise — one symbol. The concentration case the guard must not flatten.
+ */
+const QUERIES = {
+ spread: 'ingestRecords normalizeRecords enrichRecords publishRecords',
+ tail: 'publishRecords sinkRecord PipelineRecord ingestRecords',
+ precise: 'ingestRecords',
+} as const;
+type Shape = keyof typeof QUERIES;
+
+interface Probe {
+ response: string;
+ report: ExploreDiagnosticReport;
+ bytes: Map;
+}
+
+describe('CG-26 — no admitted file is starved, on any render path', () => {
+ let testDir: string;
+ let cg: CodeGraph;
+ const probes = {} as Record;
+
+ /** Admitted = the allocator reserved bytes for it. */
+ const admitted = (probe: Probe): ExploreDiagnosticFile[] =>
+ probe.report.files.filter((f) => (f.allowance ?? 0) > 0);
+ const all = (): Probe[] => Object.values(probes);
+
+ beforeAll(async () => {
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg26-'));
+ fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
+ fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
+
+ const filler = path.join(testDir, 'src', 'generated');
+ fs.mkdirSync(filler, { recursive: true });
+ for (let i = 0; i < FILLER_FILES; i++) {
+ fs.writeFileSync(
+ path.join(filler, `unit${i}.ts`),
+ `export const seed${i} = ${i};\n`
+ + `export function widget${i}(n: number): number {\n return n * ${i + 1} + seed${i};\n}\n`,
+ );
+ }
+
+ cg = CodeGraph.initSync(testDir);
+ await cg.indexAll();
+
+ const sidecar = path.join(testDir, 'explore-diag.jsonl');
+ const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
+ process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
+ try {
+ const handler = new ToolHandler(cg);
+ for (const [shape, query] of Object.entries(QUERIES) as [Shape, string][]) {
+ const result = await handler.execute('codegraph_explore', { query });
+ const response = result.content?.[0]?.text ?? '';
+ const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
+ probes[shape] = {
+ response,
+ report: JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport,
+ bytes: attributeSourceBytes(response),
+ };
+ }
+ } finally {
+ if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
+ else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
+ }
+ }, 180_000);
+
+ afterAll(() => {
+ if (cg) cg.destroy();
+ if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+ });
+
+ // ── Fixture shape — if these rot, the gates below mean nothing ─────────────
+
+ describe('fixture shape', () => {
+ it('sits on the 24K tier, where the reservations saturate the ceiling', () => {
+ expect(cg.getStats().fileCount).toBeGreaterThanOrEqual(500);
+ for (const probe of all()) expect(probe.report.budget.maxOutputChars).toBe(24000);
+ });
+
+ it('exercises both directions — the giant ranks #1 in one shape and lower in another', () => {
+ // Which shape puts it where is the ranker's business and may move; that
+ // it lands on BOTH sides across the three is what makes the gates below
+ // test the invariant rather than one arrangement of it.
+ const ranks = all().map((p) => p.report.files.find((f) => f.path === GIANT)?.rank ?? -1);
+ expect(ranks).toContain(1);
+ expect(ranks.some((r) => r > 1)).toBe(true);
+ });
+
+ it('exercises both render paths — something ships whole, something clusters', () => {
+ const modes = new Set(all().flatMap((p) => p.report.files.map((f) => f.render)));
+ expect(modes).toContain('clusters');
+ expect(modes).toContain('whole');
+ });
+ });
+
+ // ── The invariant ─────────────────────────────────────────────────────────
+
+ describe('the reservation invariant', () => {
+ it('CG-26 GATE: no file on ANY render path emits past what was still free', () => {
+ // CG-31 pinned this for `clusters` only. The whole-file arms were fit-
+ // tested against `renderCeiling - totalChars`, which is everyone's room,
+ // not this file's — so a whole render could spend a reservation the loop
+ // had already promised further down.
+ for (const [shape, probe] of Object.entries(probes) as [Shape, Probe][]) {
+ const over = probe.report.files
+ .filter((f) => f.render !== null && f.render !== 'dropped' && f.funded !== null)
+ // +1 for the render loop's own rounding on a windowed cut.
+ .filter((f) => f.emittedChars > f.funded! + 1)
+ .map((f) => `${shape}/${f.path}: ${f.emittedChars} emitted of ${f.funded} funded (${f.render})`);
+ expect(over).toEqual([]);
+ }
+ });
+
+ it('CG-26 GATE: every admitted file is delivered, whatever its rank', () => {
+ for (const [shape, probe] of Object.entries(probes) as [Shape, Probe][]) {
+ for (const rec of admitted(probe)) {
+ expect(rec.skipped, `${shape}/${rec.path} skipped`).toBeNull();
+ expect(probe.bytes.get(rec.path) ?? 0, `${shape}/${rec.path} bytes`).toBeGreaterThan(0);
+ }
+ }
+ });
+
+ it('CG-26 GATE: the rank-#1 file gets its reservation even when a file below overspends', () => {
+ // The direction CG-31's fixture could not reach: under `tail` the giant
+ // ranks below a small file and draws far past its own reservation from
+ // carry-forward slack. Rank #1 must still receive what it was promised
+ // (or its whole file, if that is less).
+ for (const [shape, probe] of Object.entries(probes) as [Shape, Probe][]) {
+ const top = admitted(probe).sort((a, b) => a.rank - b.rank)[0];
+ if (!top) continue;
+ const onDisk = fs.statSync(path.join(testDir, top.path)).size;
+ expect(probe.bytes.get(top.path) ?? 0, `${shape}/${top.path}`)
+ .toBeGreaterThanOrEqual(Math.min(top.allowance!, onDisk) * 0.9);
+ }
+ });
+
+ it('and the gate above is not vacuous — a lower-ranked file does overspend', () => {
+ const overspenders = (probe: Probe) => admitted(probe)
+ .filter((f) => f.rank > 1 && f.emittedChars > f.allowance!);
+ expect(overspenders(probes.tail).length).toBeGreaterThan(0);
+ });
+ });
+
+ // ── What the ceiling must no longer do ────────────────────────────────────
+
+ describe('the hard ceiling never throws a rendered section away', () => {
+ it('the render loop spends what it counts — nothing is allocated past the ceiling', () => {
+ // Sections used to be charged a flat 200 chars against a header that runs
+ // 300–500, so the loop over-filled and the final truncation dropped whole
+ // sections. `allocatedChars` is the pre-truncation length: it staying
+ // under the ceiling IS the accounting being exact.
+ for (const [shape, probe] of Object.entries(probes) as [Shape, Probe][]) {
+ expect(probe.report.envelope.allocatedChars, shape)
+ .toBeLessThanOrEqual(probe.report.budget.hardCeiling);
+ expect(probe.report.envelope.truncated, shape).toBe(false);
+ }
+ });
+
+ it('no file is rendered and then dropped', () => {
+ for (const probe of all()) {
+ expect(probe.report.files.filter((f) => f.render === 'dropped')).toEqual([]);
+ }
+ });
+
+ it('keeps the response inside the hard ceiling', () => {
+ for (const probe of all()) {
+ expect(probe.report.envelope.chars).toBeLessThanOrEqual(probe.report.budget.hardCeiling);
+ }
+ });
+ });
+
+ // ── The epilogue is budgeted, not discarded ───────────────────────────────
+
+ describe('the epilogue the loop budgeted for is the epilogue it emits', () => {
+ it('a response that withheld files still says so, and says to explore not Read', () => {
+ // The flat 600-char margin was neither the epilogue's size nor a bound on
+ // it, so a saturated response shipped with no pointer list and no
+ // reminders at all. Whatever else is traded away, the agent must be told
+ // an uncovered area exists and that another explore reaches it.
+ for (const [shape, probe] of Object.entries(probes) as [Shape, Probe][]) {
+ const withheld = probe.report.files.some(
+ (f) => f.render === null || (probe.bytes.get(f.path) ?? 0) === 0);
+ if (!withheld) continue;
+ expect(
+ /Not shown above|omitted for size|codegraph_explore/.test(probe.response),
+ `${shape} withheld files without saying where to look`,
+ ).toBe(true);
+ }
+ });
+
+ it('never steers the agent to Read', () => {
+ for (const probe of all()) {
+ expect(/use (the )?Read|fall back to Read(?!ing those files)/i.test(probe.response)).toBe(false);
+ }
+ });
+ });
+
+ // ── The thing the invariant must NOT become ───────────────────────────────
+
+ describe('concentration survives', () => {
+ it('a precise symbol query still puts the most source in the named file', () => {
+ const mine = probes.precise.bytes.get(GIANT) ?? 0;
+ expect(mine).toBeGreaterThan(0);
+ for (const [p, n] of probes.precise.bytes) {
+ if (p === GIANT) continue;
+ expect(mine, `${GIANT} vs ${p}`).toBeGreaterThan(n);
+ }
+ });
+
+ it('is not an even split — the named file outspends its equal share', () => {
+ const rec = probes.precise.report.files.find((f) => f.path === GIANT)!;
+ const even = probes.precise.report.budget.maxOutputChars / admitted(probes.precise).length;
+ expect(rec.emittedChars).toBeGreaterThan(even);
+ });
+ });
+});
diff --git a/__tests__/fixtures/ambient-decls-ts/package.json b/__tests__/fixtures/ambient-decls-ts/package.json
new file mode 100644
index 0000000..90cf337
--- /dev/null
+++ b/__tests__/fixtures/ambient-decls-ts/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "ambient-decls-ts-fixture",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "description": "CG-28 fixture — declaration-only files competing with implementation for one explore envelope."
+}
diff --git a/__tests__/fixtures/ambient-decls-ts/src/lib/bucket.ts b/__tests__/fixtures/ambient-decls-ts/src/lib/bucket.ts
new file mode 100644
index 0000000..15304c6
--- /dev/null
+++ b/__tests__/fixtures/ambient-decls-ts/src/lib/bucket.ts
@@ -0,0 +1,46 @@
+export interface BucketObject {
+ key: string;
+ body: ReadableStream;
+ size: number;
+}
+
+export interface Bucket {
+ put(
+ key: string,
+ value: ReadableStream,
+ options?: { httpMetadata?: { contentType?: string } },
+ ): Promise;
+ get(key: string): Promise;
+}
+
+export interface MetadataStore {
+ put(id: string, value: string): Promise;
+ get(id: string): Promise;
+}
+
+const objects = new Map();
+const rows = new Map();
+
+/** The object-storage binding. */
+export function openBucket(): Bucket {
+ return {
+ async put(key, value) {
+ objects.set(key, { key, body: value, size: 0 });
+ },
+ async get(key) {
+ return objects.get(key) ?? null;
+ },
+ };
+}
+
+/** The metadata key-value binding. */
+export function openMetadataStore(): MetadataStore {
+ return {
+ async put(id, value) {
+ rows.set(id, value);
+ },
+ async get(id) {
+ return rows.get(id) ?? null;
+ },
+ };
+}
diff --git a/__tests__/fixtures/ambient-decls-ts/src/lib/queue.ts b/__tests__/fixtures/ambient-decls-ts/src/lib/queue.ts
new file mode 100644
index 0000000..9d804bd
--- /dev/null
+++ b/__tests__/fixtures/ambient-decls-ts/src/lib/queue.ts
@@ -0,0 +1,37 @@
+export interface UploadMessageBody {
+ key: string;
+ metadataId: string;
+ contentType: string;
+}
+
+/**
+ * Publish the follow-up message for a stored upload. Batched so a burst of
+ * uploads does not open one producer call per object.
+ */
+export async function enqueueUploadMessage(body: UploadMessageBody): Promise {
+ const queue = openUploadQueue();
+ await queue.send(body, { contentType: 'json' });
+}
+
+/** Consumer side: process a batch of upload messages. */
+export async function consumeUploadBatch(messages: UploadMessageBody[]): Promise {
+ let handled = 0;
+ for (const message of messages) {
+ if (!message.key) continue;
+ handled += 1;
+ }
+ return handled;
+}
+
+interface UploadQueue {
+ send(body: UploadMessageBody, options: { contentType: string }): Promise;
+}
+
+/** The binding lookup, isolated so tests can swap it. */
+export function openUploadQueue(): UploadQueue {
+ return {
+ async send() {
+ /* binding provided by the runtime */
+ },
+ };
+}
diff --git a/__tests__/fixtures/ambient-decls-ts/src/lib/request.ts b/__tests__/fixtures/ambient-decls-ts/src/lib/request.ts
new file mode 100644
index 0000000..52fcc2e
--- /dev/null
+++ b/__tests__/fixtures/ambient-decls-ts/src/lib/request.ts
@@ -0,0 +1,44 @@
+export interface ParsedUpload {
+ ok: true;
+ key: string;
+ body: ReadableStream;
+ contentType: string;
+ width: number;
+ height: number;
+ format: string;
+}
+
+export interface ParseFailure {
+ ok: false;
+ error: string;
+}
+
+/**
+ * Pull the object key, declared dimensions and the raw body stream off an
+ * upload request. Never buffers the body — the stream is handed straight to
+ * the storage layer.
+ */
+export async function parseUploadRequest(
+ request: Request,
+): Promise {
+ const url = new URL(request.url);
+ const key = url.searchParams.get('key');
+ if (!key) return { ok: false, error: 'missing key' };
+ if (!request.body) return { ok: false, error: 'missing body' };
+
+ return {
+ ok: true,
+ key,
+ body: request.body as ReadableStream,
+ contentType: request.headers.get('content-type') ?? 'application/octet-stream',
+ width: numberParam(url, 'width'),
+ height: numberParam(url, 'height'),
+ format: url.searchParams.get('format') ?? 'jpeg',
+ };
+}
+
+function numberParam(url: URL, name: string): number {
+ const raw = url.searchParams.get(name);
+ const parsed = raw ? Number.parseInt(raw, 10) : 0;
+ return Number.isFinite(parsed) ? parsed : 0;
+}
diff --git a/__tests__/fixtures/ambient-decls-ts/src/routes/upload.ts b/__tests__/fixtures/ambient-decls-ts/src/routes/upload.ts
new file mode 100644
index 0000000..1560493
--- /dev/null
+++ b/__tests__/fixtures/ambient-decls-ts/src/routes/upload.ts
@@ -0,0 +1,66 @@
+import { streamBodyToStorage } from '../storage/stream.js';
+import { recordImageMetadata } from '../storage/metadata.js';
+import { enqueueUploadMessage } from '../lib/queue.js';
+import { parseUploadRequest } from '../lib/request.js';
+
+export interface UploadResult {
+ key: string;
+ bytes: number;
+ contentType: string;
+}
+
+/**
+ * Entry point for an upload request: parse it, stream the body into object
+ * storage, record the image metadata, then queue the follow-up work.
+ */
+export async function handleUploadRequest(request: Request): Promise {
+ const parsed = await parseUploadRequest(request);
+ if (!parsed.ok) {
+ return new Response(JSON.stringify({ error: parsed.error }), { status: 400 });
+ }
+
+ const stored = await streamBodyToStorage(parsed.body, parsed.key, parsed.contentType);
+ const metadata = await recordImageMetadata(stored.key, {
+ width: parsed.width,
+ height: parsed.height,
+ format: parsed.format,
+ bytes: stored.bytes,
+ });
+
+ await enqueueUploadMessage({
+ key: stored.key,
+ metadataId: metadata.id,
+ contentType: stored.contentType,
+ });
+
+ return new Response(JSON.stringify(summarizeUpload(stored, metadata.id)), {
+ status: 201,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+/** Shape the client sees back after a successful upload. */
+export function summarizeUpload(stored: UploadResult, metadataId: string) {
+ return {
+ key: stored.key,
+ bytes: stored.bytes,
+ contentType: stored.contentType,
+ metadataId,
+ };
+}
+
+/** Reject uploads whose declared size exceeds the per-account ceiling. */
+export function isWithinUploadLimit(bytes: number, limit: number): boolean {
+ if (!Number.isFinite(bytes) || bytes < 0) return false;
+ return bytes <= limit;
+}
+
+/** Delete-side counterpart, kept here so the route module is not a one-liner. */
+export async function handleDeleteRequest(request: Request, key: string): Promise {
+ const parsed = await parseUploadRequest(request);
+ if (!parsed.ok) {
+ return new Response(JSON.stringify({ error: parsed.error }), { status: 400 });
+ }
+ await enqueueUploadMessage({ key, metadataId: '', contentType: 'application/x-delete' });
+ return new Response(null, { status: 204 });
+}
diff --git a/__tests__/fixtures/ambient-decls-ts/src/storage/metadata.ts b/__tests__/fixtures/ambient-decls-ts/src/storage/metadata.ts
new file mode 100644
index 0000000..161b0f0
--- /dev/null
+++ b/__tests__/fixtures/ambient-decls-ts/src/storage/metadata.ts
@@ -0,0 +1,54 @@
+import { openMetadataStore } from '../lib/bucket.js';
+
+export interface ImageMetadataInput {
+ width: number;
+ height: number;
+ format: string;
+ bytes: number;
+}
+
+export interface ImageMetadataRecord extends ImageMetadataInput {
+ id: string;
+ key: string;
+ recordedAt: number;
+}
+
+/**
+ * Record the image metadata for a stored object. Writes go to the metadata
+ * store keyed by object key; the returned record carries the id the queue
+ * message references.
+ */
+export async function recordImageMetadata(
+ key: string,
+ input: ImageMetadataInput,
+): Promise {
+ const store = openMetadataStore();
+ const record: ImageMetadataRecord = {
+ ...input,
+ id: metadataIdFor(key, input),
+ key,
+ recordedAt: 0,
+ };
+ await store.put(record.id, JSON.stringify(record));
+ return record;
+}
+
+/** Deterministic id so a retried upload records the same metadata row. */
+export function metadataIdFor(key: string, input: ImageMetadataInput): string {
+ return `${key}:${input.format}:${input.width}x${input.height}`;
+}
+
+/** Read a metadata record back for the download and listing paths. */
+export async function loadImageMetadata(id: string): Promise {
+ const store = openMetadataStore();
+ const raw = await store.get(id);
+ return raw ? (JSON.parse(raw) as ImageMetadataRecord) : null;
+}
+
+/** Normalize a client-declared format string to the canonical set. */
+export function normalizeFormat(format: string): string {
+ const lowered = format.trim().toLowerCase();
+ if (lowered === 'jpg') return 'jpeg';
+ if (lowered === 'tif') return 'tiff';
+ return lowered;
+}
diff --git a/__tests__/fixtures/ambient-decls-ts/src/storage/stream.ts b/__tests__/fixtures/ambient-decls-ts/src/storage/stream.ts
new file mode 100644
index 0000000..76c784d
--- /dev/null
+++ b/__tests__/fixtures/ambient-decls-ts/src/storage/stream.ts
@@ -0,0 +1,86 @@
+import { openBucket } from '../lib/bucket.js';
+import type { StorageFailure, UploadTelemetry } from './types.js';
+
+export interface StoredObject {
+ key: string;
+ bytes: number;
+ contentType: string;
+}
+
+/**
+ * Stream a request body into object storage without buffering it in memory.
+ * The body is piped through a counting transform so the byte total is known
+ * by the time the put resolves.
+ */
+export async function streamBodyToStorage(
+ body: ReadableStream,
+ key: string,
+ contentType: string,
+): Promise {
+ const bucket = openBucket();
+ const counter = createByteCounter();
+ const piped = body.pipeThrough(counter.transform, { preventClose: false });
+
+ await bucket.put(key, piped, { httpMetadata: { contentType } });
+
+ return { key, bytes: counter.total(), contentType };
+}
+
+/**
+ * A transform stream that counts the bytes flowing through it. Separated from
+ * the pipe above so the byte total can be read after the stream settles.
+ */
+export function createByteCounter() {
+ let total = 0;
+ const transform = new TransformStream({
+ transform(chunk, controller) {
+ total += chunk.byteLength;
+ controller.enqueue(chunk);
+ },
+ });
+ return { transform, total: () => total };
+}
+
+/**
+ * Read a stored object back out of the bucket as a stream, for the download
+ * path. Mirrors the upload side so both directions live in one module.
+ */
+export async function readObjectStream(key: string): Promise | null> {
+ const bucket = openBucket();
+ const object = await bucket.get(key);
+ if (!object) return null;
+ return object.body;
+}
+
+/** Timing/retry record for one stored object, handed to the metrics sink. */
+export function telemetryFor(stored: StoredObject, durationMs: number): UploadTelemetry {
+ return { key: stored.key, bytes: stored.bytes, durationMs, retries: 0 };
+}
+
+/** Describe a failed stage so the caller can report it without re-deriving it. */
+export function storageFailure(
+ key: string,
+ stage: StorageFailure['stage'],
+ message: string,
+): StorageFailure {
+ return { key, stage, message };
+}
+
+/** Cap a stream at `limit` bytes, erroring out rather than storing an overrun. */
+export function limitStream(
+ source: ReadableStream,
+ limit: number,
+): ReadableStream {
+ let seen = 0;
+ const guard = new TransformStream({
+ transform(chunk, controller) {
+ seen += chunk.byteLength;
+ if (seen > limit) {
+ controller.error(new Error(`upload exceeded ${limit} bytes`));
+ return;
+ }
+ controller.enqueue(chunk);
+ },
+ });
+ return source.pipeThrough(guard);
+}
diff --git a/__tests__/fixtures/ambient-decls-ts/src/storage/types.ts b/__tests__/fixtures/ambient-decls-ts/src/storage/types.ts
new file mode 100644
index 0000000..53185a2
--- /dev/null
+++ b/__tests__/fixtures/ambient-decls-ts/src/storage/types.ts
@@ -0,0 +1,18 @@
+/**
+ * Shared shapes for the storage layer. Declaration-only like the ambient files
+ * under `types/` — but the modules that answer a flow question are typed BY it,
+ * so it is part of that answer's structure rather than a global shim.
+ */
+
+export interface UploadTelemetry {
+ key: string;
+ bytes: number;
+ durationMs: number;
+ retries: number;
+}
+
+export interface StorageFailure {
+ key: string;
+ stage: 'parse' | 'stream' | 'metadata' | 'queue';
+ message: string;
+}
diff --git a/__tests__/fixtures/ambient-decls-ts/types/platform-shims.d.ts b/__tests__/fixtures/ambient-decls-ts/types/platform-shims.d.ts
new file mode 100644
index 0000000..9b03b63
--- /dev/null
+++ b/__tests__/fixtures/ambient-decls-ts/types/platform-shims.d.ts
@@ -0,0 +1,212 @@
+// Hand-maintained ambient declarations for the parts of the platform our
+// runtime exposes but the published typings do not cover yet. Edit freely —
+// nothing regenerates this file. Kept alongside the app so module augmentation
+// and the global shims live in one place.
+
+declare global {
+ interface UploadStorage {
+ put(
+ key: string,
+ body: ReadableStream,
+ options?: UploadPutOptions,
+ ): Promise;
+ get(key: string): Promise;
+ head(key: string): Promise;
+ delete(key: string | string[]): Promise;
+ list(options?: UploadListOptions): Promise;
+ }
+
+ interface StoredUploadObject {
+ readonly key: string;
+ readonly size: number;
+ readonly etag: string;
+ readonly uploaded: Date;
+ readonly body: ReadableStream;
+ readonly contentType: string;
+ readonly metadata?: ImageMetadataShim;
+ arrayBuffer(): Promise;
+ text(): Promise;
+ json(): Promise;
+ }
+
+ interface StoredUploadHead {
+ readonly key: string;
+ readonly size: number;
+ readonly etag: string;
+ readonly uploaded: Date;
+ readonly contentType: string;
+ }
+
+ interface UploadPutOptions {
+ contentType?: string;
+ cacheControl?: string;
+ customMetadata?: Record;
+ checksum?: string;
+ storageClass?: 'standard' | 'infrequent';
+ }
+
+ interface UploadListOptions {
+ prefix?: string;
+ cursor?: string;
+ limit?: number;
+ delimiter?: string;
+ include?: ('metadata' | 'contentType')[];
+ }
+
+ interface UploadListResult {
+ objects: StoredUploadHead[];
+ truncated: boolean;
+ cursor?: string;
+ prefixes: string[];
+ }
+
+ interface ImageMetadataShim {
+ format: string;
+ fileSize: number;
+ width: number;
+ height: number;
+ orientation?: number;
+ colorSpace?: string;
+ }
+
+ interface MetadataRowShim {
+ id: string;
+ key: string;
+ recordedAt: number;
+ format: string;
+ bytes: number;
+ width: number;
+ height: number;
+ }
+
+ interface MetadataStoreShim {
+ put(id: string, value: string, options?: MetadataPutOptions): Promise;
+ get(id: string): Promise;
+ getWithMetadata(id: string): Promise<{ value: string | null; metadata: T | null }>;
+ delete(id: string): Promise;
+ list(options?: MetadataListOptions): Promise;
+ }
+
+ interface MetadataPutOptions {
+ expiration?: number;
+ expirationTtl?: number;
+ metadata?: unknown;
+ }
+
+ interface MetadataListOptions {
+ prefix?: string | null;
+ cursor?: string | null;
+ limit?: number;
+ }
+
+ interface MetadataListResult {
+ keys: { name: string; expiration?: number }[];
+ list_complete: boolean;
+ cursor?: string;
+ }
+
+ interface UploadQueueShim {
+ send(body: Body, options?: UploadSendOptions): Promise;
+ sendBatch(bodies: Iterable>): Promise;
+ }
+
+ interface UploadSendOptions {
+ contentType?: UploadContentType;
+ delaySeconds?: number;
+ }
+
+ type UploadContentType = 'text' | 'bytes' | 'json' | 'v8';
+
+ interface UploadSendRequest {
+ body: Body;
+ options?: UploadSendOptions;
+ }
+
+ interface UploadMessageShim {
+ readonly id: string;
+ readonly timestamp: Date;
+ readonly body: Body;
+ readonly attempts: number;
+ retry(options?: UploadRetryOptions): void;
+ ack(): void;
+ }
+
+ interface UploadRetryOptions {
+ delaySeconds?: number;
+ }
+
+ interface UploadMessageBatch {
+ readonly messages: readonly UploadMessageShim[];
+ readonly queue: string;
+ retryAll(options?: UploadRetryOptions): void;
+ ackAll(): void;
+ }
+
+ interface StreamPipeOptionsShim {
+ preventClose?: boolean;
+ preventAbort?: boolean;
+ preventCancel?: boolean;
+ signal?: AbortSignal;
+ }
+
+ interface ByteCounterShim {
+ readonly transform: TransformStream;
+ total(): number;
+ }
+
+ interface StreamLimitShim {
+ readonly limit: number;
+ readonly seen: number;
+ exceeded(): boolean;
+ }
+
+ interface RequestBodyShim {
+ readonly body: ReadableStream | null;
+ readonly bodyUsed: boolean;
+ readonly headers: Headers;
+ readonly url: string;
+ arrayBuffer(): Promise;
+ formData(): Promise;
+ blob(): Promise;
+ }
+
+ interface ParsedUploadShim {
+ key: string;
+ contentType: string;
+ width: number;
+ height: number;
+ format: string;
+ }
+
+ interface ImageTransformerShim {
+ transform(transform: ImageTransformShim): ImageTransformerShim;
+ output(options: ImageOutputShim): Promise;
+ }
+
+ interface ImageTransformShim {
+ width?: number;
+ height?: number;
+ fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
+ rotate?: number;
+ }
+
+ interface ImageOutputShim {
+ format?: string;
+ quality?: number;
+ background?: string;
+ }
+
+ interface ImageResultShim {
+ contentType(): string;
+ image(): ReadableStream;
+ response(): Response;
+ }
+
+ interface UploadEnvShim {
+ UPLOADS: UploadStorage;
+ METADATA: MetadataStoreShim;
+ UPLOAD_QUEUE: UploadQueueShim;
+ }
+}
+
+export {};
diff --git a/__tests__/fixtures/ambient-decls-ts/types/worker-configuration.d.ts b/__tests__/fixtures/ambient-decls-ts/types/worker-configuration.d.ts
new file mode 100644
index 0000000..d430f8c
--- /dev/null
+++ b/__tests__/fixtures/ambient-decls-ts/types/worker-configuration.d.ts
@@ -0,0 +1,271 @@
+// Generated by Wrangler by running `wrangler types` (hash: 4f1c8ad2b90e)
+// Runtime types generated with workerd@1.20260701.0 2026-07-01 nodejs_compat
+declare namespace Cloudflare {
+ interface Env {
+ UPLOADS: R2Bucket;
+ METADATA: KVNamespace;
+ UPLOAD_QUEUE: Queue;
+ IMAGES: ImagesBinding;
+ }
+}
+
+interface UploadMessageBody {
+ key: string;
+ metadataId: string;
+ contentType: string;
+}
+
+interface R2Bucket {
+ head(key: string): Promise;
+ get(key: string, options?: R2GetOptions): Promise;
+ put(
+ key: string,
+ value: ReadableStream | ArrayBuffer | string | null,
+ options?: R2PutOptions,
+ ): Promise;
+ delete(keys: string | string[]): Promise;
+ list(options?: R2ListOptions): Promise;
+ createMultipartUpload(key: string, options?: R2MultipartOptions): Promise;
+ resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload;
+}
+
+interface R2Object {
+ readonly key: string;
+ readonly version: string;
+ readonly size: number;
+ readonly etag: string;
+ readonly httpEtag: string;
+ readonly checksums: R2Checksums;
+ readonly uploaded: Date;
+ readonly httpMetadata?: R2HTTPMetadata;
+ readonly customMetadata?: Record;
+ readonly range?: R2Range;
+ readonly storageClass: string;
+ writeHttpMetadata(headers: Headers): void;
+}
+
+interface R2ObjectBody extends R2Object {
+ get body(): ReadableStream;
+ get bodyUsed(): boolean;
+ arrayBuffer(): Promise;
+ text(): Promise;
+ json(): Promise;
+ blob(): Promise;
+ bytes(): Promise;
+}
+
+interface R2GetOptions {
+ onlyIf?: R2Conditional | Headers;
+ range?: R2Range;
+ ssecKey?: ArrayBuffer | string;
+}
+
+interface R2PutOptions {
+ onlyIf?: R2Conditional | Headers;
+ httpMetadata?: R2HTTPMetadata | Headers;
+ customMetadata?: Record;
+ md5?: ArrayBuffer | string;
+ sha1?: ArrayBuffer | string;
+ sha256?: ArrayBuffer | string;
+ storageClass?: string;
+ ssecKey?: ArrayBuffer | string;
+}
+
+interface R2ListOptions {
+ limit?: number;
+ prefix?: string;
+ cursor?: string;
+ delimiter?: string;
+ startAfter?: string;
+ include?: ('httpMetadata' | 'customMetadata')[];
+}
+
+interface R2Objects {
+ objects: R2Object[];
+ truncated: boolean;
+ cursor?: string;
+ delimitedPrefixes: string[];
+}
+
+interface R2MultipartOptions {
+ httpMetadata?: R2HTTPMetadata | Headers;
+ customMetadata?: Record;
+ storageClass?: string;
+}
+
+interface R2MultipartUpload {
+ readonly key: string;
+ readonly uploadId: string;
+ uploadPart(
+ partNumber: number,
+ value: ReadableStream | ArrayBuffer | string | Blob,
+ ): Promise;
+ abort(): Promise;
+ complete(uploadedParts: R2UploadedPart[]): Promise;
+}
+
+interface R2UploadedPart {
+ partNumber: number;
+ etag: string;
+}
+
+interface R2HTTPMetadata {
+ contentType?: string;
+ contentLanguage?: string;
+ contentDisposition?: string;
+ contentEncoding?: string;
+ cacheControl?: string;
+ cacheExpiry?: Date;
+}
+
+interface R2Checksums {
+ readonly md5?: ArrayBuffer;
+ readonly sha1?: ArrayBuffer;
+ readonly sha256?: ArrayBuffer;
+ toJSON(): R2StringChecksums;
+}
+
+interface R2StringChecksums {
+ md5?: string;
+ sha1?: string;
+ sha256?: string;
+}
+
+interface R2Conditional {
+ etagMatches?: string;
+ etagDoesNotMatch?: string;
+ uploadedBefore?: Date;
+ uploadedAfter?: Date;
+ secondsGranularity?: boolean;
+}
+
+interface R2Range {
+ offset?: number;
+ length?: number;
+ suffix?: number;
+}
+
+interface KVNamespace {
+ get(key: Key, options?: Partial>): Promise;
+ getWithMetadata(
+ key: Key,
+ options?: Partial>,
+ ): Promise>;
+ put(
+ key: Key,
+ value: string | ArrayBuffer | ArrayBufferView | ReadableStream,
+ options?: KVNamespacePutOptions,
+ ): Promise;
+ delete(key: Key): Promise;
+ list(
+ options?: KVNamespaceListOptions,
+ ): Promise>;
+}
+
+interface KVNamespaceGetOptions {
+ type: Type;
+ cacheTtl?: number;
+}
+
+interface KVNamespacePutOptions {
+ expiration?: number;
+ expirationTtl?: number;
+ metadata?: unknown | null;
+}
+
+interface KVNamespaceListOptions {
+ limit?: number;
+ prefix?: string | null;
+ cursor?: string | null;
+}
+
+interface KVNamespaceListResult {
+ keys: KVNamespaceListKey[];
+ list_complete: boolean;
+ cursor?: string;
+}
+
+interface KVNamespaceListKey {
+ name: Key;
+ expiration?: number;
+ metadata?: Metadata;
+}
+
+interface KVNamespaceGetWithMetadataResult {
+ value: Value | null;
+ metadata: Metadata | null;
+ cacheStatus: string | null;
+}
+
+interface Queue {
+ send(message: Body, options?: QueueSendOptions): Promise;
+ sendBatch(messages: Iterable>): Promise;
+}
+
+interface QueueSendOptions {
+ contentType?: QueueContentType;
+ delaySeconds?: number;
+}
+
+type QueueContentType = 'text' | 'bytes' | 'json' | 'v8';
+
+interface MessageSendRequest {
+ body: Body;
+ options?: QueueSendOptions;
+}
+
+interface Message {
+ readonly id: string;
+ readonly timestamp: Date;
+ readonly body: Body;
+ readonly attempts: number;
+ retry(options?: QueueRetryOptions): void;
+ ack(): void;
+}
+
+interface QueueRetryOptions {
+ delaySeconds?: number;
+}
+
+interface MessageBatch {
+ readonly messages: readonly Message[];
+ readonly queue: string;
+ retryAll(options?: QueueRetryOptions): void;
+ ackAll(): void;
+}
+
+interface ImagesBinding {
+ info(stream: ReadableStream): Promise;
+ input(stream: ReadableStream): ImageTransformer;
+}
+
+interface ImageMetadata {
+ format: string;
+ fileSize: number;
+ width: number;
+ height: number;
+}
+
+interface ImageTransformer {
+ transform(transform: ImageTransform): ImageTransformer;
+ output(options: ImageOutputOptions): Promise;
+}
+
+interface ImageTransform {
+ width?: number;
+ height?: number;
+ fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
+ rotate?: number;
+}
+
+interface ImageOutputOptions {
+ format?: string;
+ quality?: number;
+ background?: string;
+}
+
+interface ImageTransformationResult {
+ contentType(): string;
+ image(): ReadableStream;
+ response(): Response;
+}
diff --git a/__tests__/fixtures/dense-header-ts/package.json b/__tests__/fixtures/dense-header-ts/package.json
new file mode 100644
index 0000000..52123c6
--- /dev/null
+++ b/__tests__/fixtures/dense-header-ts/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "dense-header-fixture",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module"
+}
diff --git a/__tests__/fixtures/dense-header-ts/src/core/queue.ts b/__tests__/fixtures/dense-header-ts/src/core/queue.ts
new file mode 100644
index 0000000..e490cdf
--- /dev/null
+++ b/__tests__/fixtures/dense-header-ts/src/core/queue.ts
@@ -0,0 +1,23 @@
+import type { URLSessionTask } from './types';
+
+export class RequestQueue {
+ private readonly waiting: URLSessionTask[] = [];
+ private running = 0;
+
+ enqueue(task: URLSessionTask, limit: number): void {
+ if (this.running < limit) {
+ this.running += 1;
+ return;
+ }
+ this.waiting.push(task);
+ }
+
+ release(): URLSessionTask | undefined {
+ this.running = Math.max(0, this.running - 1);
+ return this.waiting.shift();
+ }
+
+ get depth(): number {
+ return this.waiting.length;
+ }
+}
diff --git a/__tests__/fixtures/dense-header-ts/src/core/request-builder.ts b/__tests__/fixtures/dense-header-ts/src/core/request-builder.ts
new file mode 100644
index 0000000..7625596
--- /dev/null
+++ b/__tests__/fixtures/dense-header-ts/src/core/request-builder.ts
@@ -0,0 +1,27 @@
+import type { CachePolicy, URLRequest } from './types';
+
+export function buildURLRequest(options: {
+ url: string;
+ method: string;
+ body?: Uint8Array;
+ headers: Record;
+ timeout: number;
+ cachePolicy: CachePolicy;
+}): URLRequest {
+ const headers = { ...options.headers };
+ if (options.body && !headers['content-length']) {
+ headers['content-length'] = String(options.body.length);
+ }
+ return {
+ url: normalize(options.url),
+ method: options.method.toUpperCase(),
+ headers,
+ body: options.body,
+ timeout: options.timeout,
+ cachePolicy: options.cachePolicy,
+ };
+}
+
+function normalize(url: string): string {
+ return url.endsWith('/') && url.split('/').length > 4 ? url.slice(0, -1) : url;
+}
diff --git a/__tests__/fixtures/dense-header-ts/src/core/task-factory.ts b/__tests__/fixtures/dense-header-ts/src/core/task-factory.ts
new file mode 100644
index 0000000..0f23626
--- /dev/null
+++ b/__tests__/fixtures/dense-header-ts/src/core/task-factory.ts
@@ -0,0 +1,23 @@
+import type { RequestDelegate, TaskResponse, URLRequest, URLSessionTask } from './types';
+
+export function makeTask(options: {
+ identifier: number;
+ request: URLRequest;
+ delegate: RequestDelegate;
+ allowsCellularAccess: boolean;
+ waitsForConnectivity: boolean;
+ resourceTimeout: number;
+}): URLSessionTask {
+ const handlers: Array<(response: TaskResponse) => void> = [];
+ return {
+ identifier: options.identifier,
+ request: options.request,
+ state: 'initialized',
+ cancel() { this.state = 'cancelled'; },
+ onComplete(handler) { handlers.push(handler); },
+ };
+}
+
+export function resumeTask(task: URLSessionTask): void {
+ task.state = 'resumed';
+}
diff --git a/__tests__/fixtures/dense-header-ts/src/core/types.ts b/__tests__/fixtures/dense-header-ts/src/core/types.ts
new file mode 100644
index 0000000..d444e0a
--- /dev/null
+++ b/__tests__/fixtures/dense-header-ts/src/core/types.ts
@@ -0,0 +1,42 @@
+export type CachePolicy = 'useProtocolCachePolicy' | 'reloadIgnoringLocalCacheData' | 'returnCacheDataElseLoad';
+export type RequestState = 'initialized' | 'resumed' | 'suspended' | 'cancelled' | 'finished';
+
+export interface URLRequest {
+ url: string;
+ method: string;
+ headers: Record;
+ body?: Uint8Array;
+ timeout: number;
+ cachePolicy: CachePolicy;
+}
+
+export interface TaskResponse {
+ status: number;
+ headers: Record;
+ body: Uint8Array;
+}
+
+export interface URLSessionTask {
+ identifier: number;
+ request: URLRequest;
+ state: RequestState;
+ cancel(): void;
+ onComplete(handler: (response: TaskResponse) => void): void;
+}
+
+export interface Adapter { adapt(request: URLRequest): URLRequest; }
+export interface Serializer { serialize(value: unknown): Uint8Array; }
+export interface Validator { validate(response: TaskResponse): { ok: boolean; reason?: string }; }
+export interface Retrier { shouldRetry(response: TaskResponse, verdict: { ok: boolean }): boolean; }
+export interface RedirectHandler { resolve(location: string, original: URLRequest): { url: string; method: string; body?: Uint8Array } | null; }
+export interface TrustEvaluator { evaluate(host: string): boolean; }
+export interface Credential { apply(request: URLRequest): URLRequest; }
+export interface Interceptor { name: string; adapt(request: URLRequest, session: unknown): Promise; }
+export interface RequestDelegate { willSend(request: URLRequest): void; }
+export interface EventMonitor {
+ didAdaptRequest(request: URLRequest, interceptor: string): void;
+ didCreateTask(task: URLSessionTask, request: URLRequest): void;
+ didResumeTask(task: URLSessionTask): void;
+ didRetryTask(task: URLSessionTask, previousIdentifier: number): void;
+ didCompleteTask(task: URLSessionTask, response: TaskResponse): void;
+}
diff --git a/__tests__/fixtures/dense-header-ts/src/index.ts b/__tests__/fixtures/dense-header-ts/src/index.ts
new file mode 100644
index 0000000..e17cc6d
--- /dev/null
+++ b/__tests__/fixtures/dense-header-ts/src/index.ts
@@ -0,0 +1,3 @@
+export { Session } from './net/session';
+export { RequestQueue } from './core/queue';
+export { buildURLRequest } from './core/request-builder';
diff --git a/__tests__/fixtures/dense-header-ts/src/net/session.ts b/__tests__/fixtures/dense-header-ts/src/net/session.ts
new file mode 100644
index 0000000..77660e9
--- /dev/null
+++ b/__tests__/fixtures/dense-header-ts/src/net/session.ts
@@ -0,0 +1,285 @@
+import type {
+ Adapter,
+ CachePolicy,
+ Credential,
+ EventMonitor,
+ Interceptor,
+ RedirectHandler,
+ RequestDelegate,
+ RequestState,
+ Retrier,
+ Serializer,
+ TrustEvaluator,
+ URLRequest,
+ URLSessionTask,
+ Validator,
+} from '../core/types';
+import { buildURLRequest } from '../core/request-builder';
+import { makeTask, resumeTask } from '../core/task-factory';
+import { RequestQueue } from '../core/queue';
+
+/**
+ * The shape density-first ranking exists for: a class whose top-of-file header
+ * is a long, tightly-packed property list — dozens of adjacent declarations,
+ * each individually trivial — while the methods a flow question actually asks
+ * about live hundreds of lines below it.
+ *
+ * Ranked by density alone the header wins the file's whole budget and the
+ * methods are buried. The ranking puts importance first for exactly this
+ * reason, and density only breaks ties inside one importance tier.
+ */
+export class Session {
+ readonly identifier: string;
+ readonly adapter: Adapter;
+ readonly serializer: Serializer;
+ readonly validator: Validator;
+ readonly retrier: Retrier;
+ readonly redirectHandler: RedirectHandler;
+ readonly trustEvaluator: TrustEvaluator;
+ readonly eventMonitor: EventMonitor;
+ readonly cachePolicy: CachePolicy;
+ readonly credential: Credential | null;
+ readonly interceptors: Interceptor[];
+ readonly delegate: RequestDelegate;
+ readonly queue: RequestQueue;
+ readonly startRequestsImmediately: boolean;
+ readonly maximumConnectionsPerHost: number;
+ readonly timeoutIntervalForRequest: number;
+ readonly timeoutIntervalForResource: number;
+ readonly allowsCellularAccess: boolean;
+ readonly waitsForConnectivity: boolean;
+ readonly httpShouldUsePipelining: boolean;
+ readonly httpShouldSetCookies: boolean;
+ readonly httpMaximumConnectionsPerHost: number;
+ readonly sessionConfigurationName: string;
+ readonly requestState: RequestState;
+ readonly defaultHeaders: Record;
+ readonly userAgent: string;
+ readonly acceptEncoding: string;
+ readonly acceptLanguage: string;
+ private taskCounter = 0;
+ private active = new Map();
+
+ constructor(options: Partial & { identifier: string }) {
+ this.identifier = options.identifier;
+ this.adapter = options.adapter!;
+ this.serializer = options.serializer!;
+ this.validator = options.validator!;
+ this.retrier = options.retrier!;
+ this.redirectHandler = options.redirectHandler!;
+ this.trustEvaluator = options.trustEvaluator!;
+ this.eventMonitor = options.eventMonitor!;
+ this.cachePolicy = options.cachePolicy ?? 'useProtocolCachePolicy';
+ this.credential = options.credential ?? null;
+ this.interceptors = options.interceptors ?? [];
+ this.delegate = options.delegate!;
+ this.queue = options.queue ?? new RequestQueue();
+ this.startRequestsImmediately = options.startRequestsImmediately ?? true;
+ this.maximumConnectionsPerHost = options.maximumConnectionsPerHost ?? 6;
+ this.timeoutIntervalForRequest = options.timeoutIntervalForRequest ?? 60;
+ this.timeoutIntervalForResource = options.timeoutIntervalForResource ?? 604800;
+ this.allowsCellularAccess = options.allowsCellularAccess ?? true;
+ this.waitsForConnectivity = options.waitsForConnectivity ?? false;
+ this.httpShouldUsePipelining = options.httpShouldUsePipelining ?? false;
+ this.httpShouldSetCookies = options.httpShouldSetCookies ?? true;
+ this.httpMaximumConnectionsPerHost = options.httpMaximumConnectionsPerHost ?? 6;
+ this.sessionConfigurationName = options.sessionConfigurationName ?? 'default';
+ this.requestState = options.requestState ?? 'initialized';
+ this.defaultHeaders = options.defaultHeaders ?? {};
+ this.userAgent = options.userAgent ?? 'session/1.0';
+ this.acceptEncoding = options.acceptEncoding ?? 'br;q=1.0, gzip;q=0.9';
+ this.acceptLanguage = options.acceptLanguage ?? 'en;q=1.0';
+ }
+
+ // -- configuration accessors ----------------------------------------------
+ // Individually trivial, adjacent, and dense. On the density tiebreak alone
+ // this block outranks anything with a body worth reading.
+
+ get isBackground(): boolean {
+ return this.sessionConfigurationName === 'background';
+ }
+
+ get connectionLimit(): number {
+ return Math.min(this.maximumConnectionsPerHost, this.httpMaximumConnectionsPerHost);
+ }
+
+ get headerDefaults(): Record {
+ return { ...this.defaultHeaders, 'user-agent': this.userAgent };
+ }
+
+ get acceptHeaders(): Record {
+ return { 'accept-encoding': this.acceptEncoding, 'accept-language': this.acceptLanguage };
+ }
+
+ get activeCount(): number {
+ return this.active.size;
+ }
+
+ get isIdle(): boolean {
+ return this.active.size === 0;
+ }
+
+ get nextIdentifier(): number {
+ return this.taskCounter + 1;
+ }
+
+ get description(): string {
+ return `Session(${this.identifier}, ${this.sessionConfigurationName})`;
+ }
+
+ cancelAll(): void {
+ for (const task of this.active.values()) task.cancel();
+ this.active.clear();
+ }
+
+ taskFor(identifier: number): URLSessionTask | undefined {
+ return this.active.get(identifier);
+ }
+
+ headers(): Record {
+ return { ...this.headerDefaults, ...this.acceptHeaders };
+ }
+
+ withUserAgent(userAgent: string): Session {
+ return new Session({ ...this, identifier: this.identifier, userAgent });
+ }
+
+ withTimeout(seconds: number): Session {
+ return new Session({ ...this, identifier: this.identifier, timeoutIntervalForRequest: seconds });
+ }
+
+ withInterceptor(interceptor: Interceptor): Session {
+ return new Session({
+ ...this,
+ identifier: this.identifier,
+ interceptors: [...this.interceptors, interceptor],
+ });
+ }
+
+ withCredential(credential: Credential): Session {
+ return new Session({ ...this, identifier: this.identifier, credential });
+ }
+
+ withCachePolicy(cachePolicy: CachePolicy): Session {
+ return new Session({ ...this, identifier: this.identifier, cachePolicy });
+ }
+
+ withQueue(queue: RequestQueue): Session {
+ return new Session({ ...this, identifier: this.identifier, queue });
+ }
+
+ withAdapter(adapter: Adapter): Session {
+ return new Session({ ...this, identifier: this.identifier, adapter });
+ }
+
+ withValidator(validator: Validator): Session {
+ return new Session({ ...this, identifier: this.identifier, validator });
+ }
+
+ withRetrier(retrier: Retrier): Session {
+ return new Session({ ...this, identifier: this.identifier, retrier });
+ }
+
+ withMonitor(eventMonitor: EventMonitor): Session {
+ return new Session({ ...this, identifier: this.identifier, eventMonitor });
+ }
+
+ // -- the flow ---------------------------------------------------------------
+ //
+ // The methods below are what a "how does a request get built and sent" question
+ // is about, and they sit hundreds of lines under the header block.
+
+ /**
+ * Turn a convenience call into a URLRequest, hand it to the adapter chain and
+ * start the resulting task. The entry point of the whole flow.
+ */
+ async perform(url: string, method: string, body?: Uint8Array): Promise {
+ const initial = buildURLRequest({
+ url,
+ method,
+ body,
+ headers: this.headers(),
+ timeout: this.timeoutIntervalForRequest,
+ cachePolicy: this.cachePolicy,
+ });
+ const adapted = await this.adapt(initial);
+ return this.didCreateURLRequest(adapted);
+ }
+
+ /**
+ * Every interceptor gets a chance to rewrite the request before it becomes a
+ * task. Runs in registration order, and a thrown error aborts the whole call.
+ */
+ private async adapt(request: URLRequest): Promise {
+ let current = request;
+ for (const interceptor of this.interceptors) {
+ current = await interceptor.adapt(current, this);
+ this.eventMonitor.didAdaptRequest(current, interceptor.name);
+ }
+ if (this.credential) current = this.credential.apply(current);
+ return current;
+ }
+
+ /**
+ * The adapted request is final: build the task around it, register it and —
+ * unless the session was told to wait — resume it immediately.
+ */
+ didCreateURLRequest(request: URLRequest): URLSessionTask {
+ this.taskCounter += 1;
+ const identifier = this.taskCounter;
+ const created = this.task(request, identifier);
+ this.active.set(identifier, created);
+ this.eventMonitor.didCreateTask(created, request);
+ if (this.startRequestsImmediately) this.resume(created);
+ return created;
+ }
+
+ /**
+ * Build the URLSessionTask for a request. Split out from
+ * `didCreateURLRequest` because retries rebuild the task without going back
+ * through the adapter chain.
+ */
+ task(request: URLRequest, identifier: number): URLSessionTask {
+ const created = makeTask({
+ identifier,
+ request,
+ delegate: this.delegate,
+ allowsCellularAccess: this.allowsCellularAccess,
+ waitsForConnectivity: this.waitsForConnectivity,
+ resourceTimeout: this.timeoutIntervalForResource,
+ });
+ created.onComplete((response) => {
+ this.active.delete(identifier);
+ const verdict = this.validator.validate(response);
+ if (!verdict.ok && this.retrier.shouldRetry(response, verdict)) {
+ this.retry(request, identifier);
+ return;
+ }
+ this.eventMonitor.didCompleteTask(created, response);
+ });
+ return created;
+ }
+
+ /** Put a built task on the queue and start it. */
+ resume(task: URLSessionTask): void {
+ this.queue.enqueue(task, this.connectionLimit);
+ resumeTask(task);
+ this.eventMonitor.didResumeTask(task);
+ }
+
+ /** Rebuild and restart a task the retrier asked for. */
+ private retry(request: URLRequest, previousIdentifier: number): void {
+ this.taskCounter += 1;
+ const retried = this.task(request, this.taskCounter);
+ this.active.set(this.taskCounter, retried);
+ this.eventMonitor.didRetryTask(retried, previousIdentifier);
+ this.resume(retried);
+ }
+
+ /** Follow a redirect by adapting and re-performing the new location. */
+ async follow(response: { location: string }, original: URLRequest): Promise {
+ const target = this.redirectHandler.resolve(response.location, original);
+ if (!target) throw new Error(`redirect to ${response.location} refused`);
+ return this.perform(target.url, target.method, target.body);
+ }
+}
diff --git a/__tests__/fixtures/displacement-ts/package.json b/__tests__/fixtures/displacement-ts/package.json
new file mode 100644
index 0000000..1998004
--- /dev/null
+++ b/__tests__/fixtures/displacement-ts/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "displacement-fixture",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/__tests__/fixtures/displacement-ts/src/index.ts b/__tests__/fixtures/displacement-ts/src/index.ts
new file mode 100644
index 0000000..c6eadf0
--- /dev/null
+++ b/__tests__/fixtures/displacement-ts/src/index.ts
@@ -0,0 +1,10 @@
+import { ingestRecords } from './pipeline/ingest';
+import { normalizeRecords } from './pipeline/normalize';
+import { enrichRecords } from './pipeline/enrich';
+import { publishRecords } from './pipeline/publish';
+import type { PipelineOptions, PipelineRecord, RawRecord } from './pipeline/types';
+
+/** Run one batch through every pipeline stage, in order. */
+export function runPipeline(batch: RawRecord[], options: PipelineOptions): PipelineRecord[] {
+ return publishRecords(enrichRecords(normalizeRecords(ingestRecords(batch, options), options), options), options);
+}
diff --git a/__tests__/fixtures/displacement-ts/src/pipeline/enrich.ts b/__tests__/fixtures/displacement-ts/src/pipeline/enrich.ts
new file mode 100644
index 0000000..ae098aa
--- /dev/null
+++ b/__tests__/fixtures/displacement-ts/src/pipeline/enrich.ts
@@ -0,0 +1,127 @@
+import { writeBatch } from './sink';
+import type { PipelineOptions, PipelineRecord } from './types';
+
+/** Enrich every record in a batch. */
+export function enrichRecords(records: PipelineRecord[], options: PipelineOptions): PipelineRecord[] {
+ const out: PipelineRecord[] = [];
+ for (const record of records) {
+ const tags = [...record.tags];
+ const warnings = [...record.warnings];
+ let value = record.value;
+
+ // 1. segment
+ {
+ const hit = tags.find((t) => t.startsWith('segment:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('segment: missing after enrich');
+ } else {
+ value = weightFacet(value, hit.length);
+ tags.push('segment.enri');
+ }
+ }
+
+ // 2. referrer
+ {
+ const hit = tags.find((t) => t.startsWith('referrer:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('referrer: missing after enrich');
+ } else {
+ value = blendFacet(value, hit.length);
+ tags.push('referrer.enri');
+ }
+ }
+
+ // 3. experiment
+ {
+ const hit = tags.find((t) => t.startsWith('experiment:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('experiment: missing after enrich');
+ } else {
+ value = weightFacet(value, hit.length);
+ tags.push('experiment.enri');
+ }
+ }
+
+ // 4. subscription
+ {
+ const hit = tags.find((t) => t.startsWith('subscription:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('subscription: missing after enrich');
+ } else {
+ value = blendFacet(value, hit.length);
+ tags.push('subscription.enri');
+ }
+ }
+
+ // 5. entitlement
+ {
+ const hit = tags.find((t) => t.startsWith('entitlement:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('entitlement: missing after enrich');
+ } else {
+ value = weightFacet(value, hit.length);
+ tags.push('entitlement.enri');
+ }
+ }
+
+ // 6. invoice
+ {
+ const hit = tags.find((t) => t.startsWith('invoice:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('invoice: missing after enrich');
+ } else {
+ value = blendFacet(value, hit.length);
+ tags.push('invoice.enri');
+ }
+ }
+
+ // 7. refund
+ {
+ const hit = tags.find((t) => t.startsWith('refund:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('refund: missing after enrich');
+ } else {
+ value = weightFacet(value, hit.length);
+ tags.push('refund.enri');
+ }
+ }
+
+ // 8. dispute
+ {
+ const hit = tags.find((t) => t.startsWith('dispute:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('dispute: missing after enrich');
+ } else {
+ value = blendFacet(value, hit.length);
+ tags.push('dispute.enri');
+ }
+ }
+
+ // 9. payout
+ {
+ const hit = tags.find((t) => t.startsWith('payout:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('payout: missing after enrich');
+ } else {
+ value = weightFacet(value, hit.length);
+ tags.push('payout.enri');
+ }
+ }
+
+ out.push({ ...record, value, tags: tags.slice(0, options.maxTags), warnings });
+ }
+ writeBatch('enrichRecords', out);
+ return out;
+}
+
+/** weightFacet — a small deterministic helper. */
+export function weightFacet(base: number, width: number): number {
+ const scaled = base + width * 3 - (width % 7);
+ return scaled < 0 ? 0 : scaled;
+}
+
+/** blendFacet — a small deterministic helper. */
+export function blendFacet(base: number, width: number): number {
+ const scaled = base + width * 3 - (width % 7);
+ return scaled < 0 ? 0 : scaled;
+}
diff --git a/__tests__/fixtures/displacement-ts/src/pipeline/ingest.ts b/__tests__/fixtures/displacement-ts/src/pipeline/ingest.ts
new file mode 100644
index 0000000..eccaee7
--- /dev/null
+++ b/__tests__/fixtures/displacement-ts/src/pipeline/ingest.ts
@@ -0,0 +1,541 @@
+import { scaleFacet, clampFacet } from './normalize';
+import { writeBatch } from './sink';
+import type { PipelineOptions, PipelineRecord, RawRecord } from './types';
+
+/**
+ * Ingest one batch of raw records.
+ *
+ * Every facet is unpacked in its own block so an on-call engineer can read the
+ * ingest end-to-end in one place. The shape is deliberately flat: this single
+ * function is the whole stage, which is exactly the shape that makes it the
+ * biggest cluster member in the file.
+ */
+export function ingestRecords(batch: RawRecord[], options: PipelineOptions): PipelineRecord[] {
+ const out: PipelineRecord[] = [];
+ for (const record of batch) {
+ const tags: string[] = [];
+ const warnings: string[] = [];
+ let value = 0;
+
+ // 1. identity — normalise the identity facet of the record.
+ {
+ const raw = record.payload['identity'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('identity: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('identity:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('identity: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 2. geography — normalise the geography facet of the record.
+ {
+ const raw = record.payload['geography'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('geography: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('geography:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('geography: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 3. currency — normalise the currency facet of the record.
+ {
+ const raw = record.payload['currency'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('currency: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('currency:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('currency: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 4. timestamp — normalise the timestamp facet of the record.
+ {
+ const raw = record.payload['timestamp'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('timestamp: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('timestamp:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('timestamp: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 5. channel — normalise the channel facet of the record.
+ {
+ const raw = record.payload['channel'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('channel: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('channel:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('channel: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 6. campaign — normalise the campaign facet of the record.
+ {
+ const raw = record.payload['campaign'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('campaign: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('campaign:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('campaign: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 7. device — normalise the device facet of the record.
+ {
+ const raw = record.payload['device'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('device: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('device:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('device: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 8. locale — normalise the locale facet of the record.
+ {
+ const raw = record.payload['locale'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('locale: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('locale:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('locale: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 9. consent — normalise the consent facet of the record.
+ {
+ const raw = record.payload['consent'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('consent: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('consent:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('consent: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 10. segment — normalise the segment facet of the record.
+ {
+ const raw = record.payload['segment'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('segment: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('segment:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('segment: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 11. referrer — normalise the referrer facet of the record.
+ {
+ const raw = record.payload['referrer'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('referrer: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('referrer:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('referrer: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 12. experiment — normalise the experiment facet of the record.
+ {
+ const raw = record.payload['experiment'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('experiment: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('experiment:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('experiment: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 13. subscription — normalise the subscription facet of the record.
+ {
+ const raw = record.payload['subscription'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('subscription: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('subscription:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('subscription: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 14. entitlement — normalise the entitlement facet of the record.
+ {
+ const raw = record.payload['entitlement'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('entitlement: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('entitlement:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('entitlement: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 15. invoice — normalise the invoice facet of the record.
+ {
+ const raw = record.payload['invoice'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('invoice: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('invoice:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('invoice: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 16. refund — normalise the refund facet of the record.
+ {
+ const raw = record.payload['refund'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('refund: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('refund:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('refund: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 17. dispute — normalise the dispute facet of the record.
+ {
+ const raw = record.payload['dispute'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('dispute: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('dispute:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('dispute: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 18. payout — normalise the payout facet of the record.
+ {
+ const raw = record.payload['payout'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('payout: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('payout:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('payout: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 19. shipment — normalise the shipment facet of the record.
+ {
+ const raw = record.payload['shipment'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('shipment: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('shipment:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('shipment: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 20. inventory — normalise the inventory facet of the record.
+ {
+ const raw = record.payload['inventory'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('inventory: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('inventory:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('inventory: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 21. warehouse — normalise the warehouse facet of the record.
+ {
+ const raw = record.payload['warehouse'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('warehouse: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('warehouse:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('warehouse: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 22. carrier — normalise the carrier facet of the record.
+ {
+ const raw = record.payload['carrier'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('carrier: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('carrier:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('carrier: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 23. customs — normalise the customs facet of the record.
+ {
+ const raw = record.payload['customs'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('customs: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('customs:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('customs: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 24. tariff — normalise the tariff facet of the record.
+ {
+ const raw = record.payload['tariff'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('tariff: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('tariff:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('tariff: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 25. sensor — normalise the sensor facet of the record.
+ {
+ const raw = record.payload['sensor'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('sensor: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('sensor:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('sensor: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 26. firmware — normalise the firmware facet of the record.
+ {
+ const raw = record.payload['firmware'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('firmware: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('firmware:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('firmware: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 27. telemetry — normalise the telemetry facet of the record.
+ {
+ const raw = record.payload['telemetry'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('telemetry: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('telemetry:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('telemetry: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 28. battery — normalise the battery facet of the record.
+ {
+ const raw = record.payload['battery'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('battery: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('battery:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('battery: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 29. network — normalise the network facet of the record.
+ {
+ const raw = record.payload['network'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('network: empty, dropped');
+ } else {
+ const scaled = scaleFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('network:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('network: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ // 30. roaming — normalise the roaming facet of the record.
+ {
+ const raw = record.payload['roaming'];
+ const text = typeof raw === 'string' ? raw.trim() : raw === null ? '' : String(raw);
+ if (text.length === 0 && options.dropEmpty) {
+ warnings.push('roaming: empty, dropped');
+ } else {
+ const scaled = clampFacet(text.length, options.maxTags);
+ if (Number.isFinite(scaled) && scaled !== 0) {
+ tags.push('roaming:' + text.slice(0, 24));
+ value += scaled;
+ } else if (options.strict) {
+ warnings.push('roaming: not scalable — ' + text.slice(0, 16));
+ }
+ }
+ }
+
+ out.push({
+ id: record.id,
+ source: record.source,
+ kind: options.defaultKind,
+ value,
+ tags: tags.slice(0, options.maxTags),
+ warnings,
+ });
+ }
+ writeBatch('ingest', out);
+ return out;
+}
diff --git a/__tests__/fixtures/displacement-ts/src/pipeline/normalize.ts b/__tests__/fixtures/displacement-ts/src/pipeline/normalize.ts
new file mode 100644
index 0000000..5a50a15
--- /dev/null
+++ b/__tests__/fixtures/displacement-ts/src/pipeline/normalize.ts
@@ -0,0 +1,127 @@
+import { writeBatch } from './sink';
+import type { PipelineOptions, PipelineRecord } from './types';
+
+/** Normalize every record in a batch. */
+export function normalizeRecords(records: PipelineRecord[], options: PipelineOptions): PipelineRecord[] {
+ const out: PipelineRecord[] = [];
+ for (const record of records) {
+ const tags = [...record.tags];
+ const warnings = [...record.warnings];
+ let value = record.value;
+
+ // 1. identity
+ {
+ const hit = tags.find((t) => t.startsWith('identity:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('identity: missing after normalize');
+ } else {
+ value = scaleFacet(value, hit.length);
+ tags.push('identity.norm');
+ }
+ }
+
+ // 2. geography
+ {
+ const hit = tags.find((t) => t.startsWith('geography:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('geography: missing after normalize');
+ } else {
+ value = clampFacet(value, hit.length);
+ tags.push('geography.norm');
+ }
+ }
+
+ // 3. currency
+ {
+ const hit = tags.find((t) => t.startsWith('currency:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('currency: missing after normalize');
+ } else {
+ value = scaleFacet(value, hit.length);
+ tags.push('currency.norm');
+ }
+ }
+
+ // 4. timestamp
+ {
+ const hit = tags.find((t) => t.startsWith('timestamp:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('timestamp: missing after normalize');
+ } else {
+ value = clampFacet(value, hit.length);
+ tags.push('timestamp.norm');
+ }
+ }
+
+ // 5. channel
+ {
+ const hit = tags.find((t) => t.startsWith('channel:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('channel: missing after normalize');
+ } else {
+ value = scaleFacet(value, hit.length);
+ tags.push('channel.norm');
+ }
+ }
+
+ // 6. campaign
+ {
+ const hit = tags.find((t) => t.startsWith('campaign:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('campaign: missing after normalize');
+ } else {
+ value = clampFacet(value, hit.length);
+ tags.push('campaign.norm');
+ }
+ }
+
+ // 7. device
+ {
+ const hit = tags.find((t) => t.startsWith('device:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('device: missing after normalize');
+ } else {
+ value = scaleFacet(value, hit.length);
+ tags.push('device.norm');
+ }
+ }
+
+ // 8. locale
+ {
+ const hit = tags.find((t) => t.startsWith('locale:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('locale: missing after normalize');
+ } else {
+ value = clampFacet(value, hit.length);
+ tags.push('locale.norm');
+ }
+ }
+
+ // 9. consent
+ {
+ const hit = tags.find((t) => t.startsWith('consent:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('consent: missing after normalize');
+ } else {
+ value = scaleFacet(value, hit.length);
+ tags.push('consent.norm');
+ }
+ }
+
+ out.push({ ...record, value, tags: tags.slice(0, options.maxTags), warnings });
+ }
+ writeBatch('normalizeRecords', out);
+ return out;
+}
+
+/** scaleFacet — a small deterministic helper. */
+export function scaleFacet(base: number, width: number): number {
+ const scaled = base + width * 3 - (width % 7);
+ return scaled < 0 ? 0 : scaled;
+}
+
+/** clampFacet — a small deterministic helper. */
+export function clampFacet(base: number, width: number): number {
+ const scaled = base + width * 3 - (width % 7);
+ return scaled < 0 ? 0 : scaled;
+}
diff --git a/__tests__/fixtures/displacement-ts/src/pipeline/publish.ts b/__tests__/fixtures/displacement-ts/src/pipeline/publish.ts
new file mode 100644
index 0000000..405f11b
--- /dev/null
+++ b/__tests__/fixtures/displacement-ts/src/pipeline/publish.ts
@@ -0,0 +1,127 @@
+import { writeBatch } from './sink';
+import type { PipelineOptions, PipelineRecord } from './types';
+
+/** Publish every record in a batch. */
+export function publishRecords(records: PipelineRecord[], options: PipelineOptions): PipelineRecord[] {
+ const out: PipelineRecord[] = [];
+ for (const record of records) {
+ const tags = [...record.tags];
+ const warnings = [...record.warnings];
+ let value = record.value;
+
+ // 1. shipment
+ {
+ const hit = tags.find((t) => t.startsWith('shipment:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('shipment: missing after publish');
+ } else {
+ value = rankFacet(value, hit.length);
+ tags.push('shipment.publ');
+ }
+ }
+
+ // 2. inventory
+ {
+ const hit = tags.find((t) => t.startsWith('inventory:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('inventory: missing after publish');
+ } else {
+ value = sealFacet(value, hit.length);
+ tags.push('inventory.publ');
+ }
+ }
+
+ // 3. warehouse
+ {
+ const hit = tags.find((t) => t.startsWith('warehouse:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('warehouse: missing after publish');
+ } else {
+ value = rankFacet(value, hit.length);
+ tags.push('warehouse.publ');
+ }
+ }
+
+ // 4. carrier
+ {
+ const hit = tags.find((t) => t.startsWith('carrier:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('carrier: missing after publish');
+ } else {
+ value = sealFacet(value, hit.length);
+ tags.push('carrier.publ');
+ }
+ }
+
+ // 5. customs
+ {
+ const hit = tags.find((t) => t.startsWith('customs:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('customs: missing after publish');
+ } else {
+ value = rankFacet(value, hit.length);
+ tags.push('customs.publ');
+ }
+ }
+
+ // 6. tariff
+ {
+ const hit = tags.find((t) => t.startsWith('tariff:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('tariff: missing after publish');
+ } else {
+ value = sealFacet(value, hit.length);
+ tags.push('tariff.publ');
+ }
+ }
+
+ // 7. sensor
+ {
+ const hit = tags.find((t) => t.startsWith('sensor:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('sensor: missing after publish');
+ } else {
+ value = rankFacet(value, hit.length);
+ tags.push('sensor.publ');
+ }
+ }
+
+ // 8. firmware
+ {
+ const hit = tags.find((t) => t.startsWith('firmware:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('firmware: missing after publish');
+ } else {
+ value = sealFacet(value, hit.length);
+ tags.push('firmware.publ');
+ }
+ }
+
+ // 9. telemetry
+ {
+ const hit = tags.find((t) => t.startsWith('telemetry:'));
+ if (hit === undefined) {
+ if (options.strict) warnings.push('telemetry: missing after publish');
+ } else {
+ value = rankFacet(value, hit.length);
+ tags.push('telemetry.publ');
+ }
+ }
+
+ out.push({ ...record, value, tags: tags.slice(0, options.maxTags), warnings });
+ }
+ writeBatch('publishRecords', out);
+ return out;
+}
+
+/** rankFacet — a small deterministic helper. */
+export function rankFacet(base: number, width: number): number {
+ const scaled = base + width * 3 - (width % 7);
+ return scaled < 0 ? 0 : scaled;
+}
+
+/** sealFacet — a small deterministic helper. */
+export function sealFacet(base: number, width: number): number {
+ const scaled = base + width * 3 - (width % 7);
+ return scaled < 0 ? 0 : scaled;
+}
diff --git a/__tests__/fixtures/displacement-ts/src/pipeline/sink.ts b/__tests__/fixtures/displacement-ts/src/pipeline/sink.ts
new file mode 100644
index 0000000..2f91720
--- /dev/null
+++ b/__tests__/fixtures/displacement-ts/src/pipeline/sink.ts
@@ -0,0 +1,18 @@
+import type { PipelineRecord } from './types';
+
+const sink = new Map();
+
+/** Hand a finished batch to the downstream sink. */
+export function writeBatch(batchId: string, records: PipelineRecord[]): void {
+ sink.set(batchId, records);
+}
+
+/** Read a batch back out of the sink. */
+export function readBatch(batchId: string): PipelineRecord[] {
+ return sink.get(batchId) ?? [];
+}
+
+/** Forget a batch. */
+export function dropBatch(batchId: string): void {
+ sink.delete(batchId);
+}
diff --git a/__tests__/fixtures/displacement-ts/src/pipeline/types.ts b/__tests__/fixtures/displacement-ts/src/pipeline/types.ts
new file mode 100644
index 0000000..32bf283
--- /dev/null
+++ b/__tests__/fixtures/displacement-ts/src/pipeline/types.ts
@@ -0,0 +1,25 @@
+/** One raw record as it arrives from the upstream feed. */
+export interface RawRecord {
+ id: string;
+ source: string;
+ payload: Record;
+ receivedAt: number;
+}
+
+/** A record after the pipeline has cleaned and annotated it. */
+export interface PipelineRecord {
+ id: string;
+ source: string;
+ kind: string;
+ value: number;
+ tags: string[];
+ warnings: string[];
+}
+
+/** Per-run knobs shared by every pipeline stage. */
+export interface PipelineOptions {
+ strict: boolean;
+ dropEmpty: boolean;
+ defaultKind: string;
+ maxTags: number;
+}
diff --git a/__tests__/fixtures/factory-closure-ts/package.json b/__tests__/fixtures/factory-closure-ts/package.json
new file mode 100644
index 0000000..f597b57
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "factory-closure-ts",
+ "version": "0.0.0",
+ "private": true
+}
diff --git a/__tests__/fixtures/factory-closure-ts/src/index.ts b/__tests__/fixtures/factory-closure-ts/src/index.ts
new file mode 100644
index 0000000..2a873de
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/src/index.ts
@@ -0,0 +1,23 @@
+import { createDashboardStore } from './stores/dashboard-store';
+import { createAlertsStore } from './stores/alerts-store';
+import { mountPanel } from './ui/panel';
+import { parseFilterText } from './services/filter-parser';
+import { refreshMetricCache } from './services/metric-service';
+import type { StoreDeps } from './stores/types';
+
+/** Wire a dashboard: build both stores, mount the panel, boot it. */
+export async function startDashboard(deps: StoreDeps, baseUrl: string, dashboardId: string) {
+ const store = createDashboardStore(deps, baseUrl);
+ const alerts = createAlertsStore(deps, baseUrl);
+ const panel = mountPanel(store, dashboardId);
+ await panel.boot();
+ await alerts.refreshAlerts(dashboardId);
+ return { store, alerts, panel };
+}
+
+/** Apply the filter bar's text to the dashboard store. */
+export function searchDashboard(store: ReturnType, text: string) {
+ return store.applyFilter(parseFilterText(text));
+}
+
+export { refreshMetricCache };
diff --git a/__tests__/fixtures/factory-closure-ts/src/lib/http.ts b/__tests__/fixtures/factory-closure-ts/src/lib/http.ts
new file mode 100644
index 0000000..84d254e
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/src/lib/http.ts
@@ -0,0 +1,25 @@
+/** Minimal fetch helpers the dashboard store depends on. */
+
+export interface RequestOptions {
+ retries: number;
+ timeoutMs: number;
+}
+
+export const defaultRequestOptions: RequestOptions = { retries: 2, timeoutMs: 5_000 };
+
+/** Build a query string from a plain record, skipping empty values. */
+export function toQueryString(params: Record): string {
+ const parts: string[] = [];
+ for (const [key, value] of Object.entries(params)) {
+ if (value === undefined || value === '') continue;
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
+ }
+ return parts.length > 0 ? `?${parts.join('&')}` : '';
+}
+
+/** Join a base path and a resource path without doubling the separator. */
+export function joinPath(base: string, resource: string): string {
+ if (base.endsWith('/') && resource.startsWith('/')) return base + resource.slice(1);
+ if (!base.endsWith('/') && !resource.startsWith('/')) return `${base}/${resource}`;
+ return base + resource;
+}
diff --git a/__tests__/fixtures/factory-closure-ts/src/lib/metrics.ts b/__tests__/fixtures/factory-closure-ts/src/lib/metrics.ts
new file mode 100644
index 0000000..be39ecc
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/src/lib/metrics.ts
@@ -0,0 +1,37 @@
+import type { MetricSample } from '../stores/types';
+
+/** Statistics helpers shared by the store and the panel. */
+
+export function meanOf(samples: readonly MetricSample[]): number {
+ if (samples.length === 0) return 0;
+ let total = 0;
+ for (const sample of samples) total += sample.value;
+ return total / samples.length;
+}
+
+export function medianOf(samples: readonly MetricSample[]): number {
+ if (samples.length === 0) return 0;
+ const values = samples.map((s) => s.value).sort((a, b) => a - b);
+ const mid = Math.floor(values.length / 2);
+ return values.length % 2 === 0 ? (values[mid - 1]! + values[mid]!) / 2 : values[mid]!;
+}
+
+export function rateOfChange(samples: readonly MetricSample[]): number {
+ if (samples.length < 2) return 0;
+ const ordered = samples.slice().sort((a, b) => a.at - b.at);
+ const first = ordered[0]!;
+ const last = ordered[ordered.length - 1]!;
+ const elapsed = last.at - first.at;
+ return elapsed > 0 ? (last.value - first.value) / elapsed : 0;
+}
+
+export function bucketByHour(samples: readonly MetricSample[]): Map {
+ const buckets = new Map();
+ for (const sample of samples) {
+ const hour = Math.floor(sample.at / 3_600_000);
+ const bucket = buckets.get(hour);
+ if (bucket) bucket.push(sample);
+ else buckets.set(hour, [sample]);
+ }
+ return buckets;
+}
diff --git a/__tests__/fixtures/factory-closure-ts/src/services/filter-parser.ts b/__tests__/fixtures/factory-closure-ts/src/services/filter-parser.ts
new file mode 100644
index 0000000..293dbb0
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/src/services/filter-parser.ts
@@ -0,0 +1,62 @@
+import type { FilterSpec } from '../stores/types';
+
+/** Parse the dashboard's filter bar text into filter specs. */
+
+const OPERATORS: Record = {
+ ':': 'eq',
+ '~': 'contains',
+ '>': 'gt',
+ '<': 'lt',
+};
+
+/** `title~sales kind:chart column>3` → three specs. */
+export function parseFilterText(text: string): FilterSpec[] {
+ const specs: FilterSpec[] = [];
+ for (const token of tokenize(text)) {
+ const spec = parseToken(token);
+ if (spec) specs.push(spec);
+ }
+ return specs;
+}
+
+/** Split on whitespace, honouring double-quoted values. */
+export function tokenize(text: string): string[] {
+ const tokens: string[] = [];
+ let current = '';
+ let quoted = false;
+ for (const ch of text) {
+ if (ch === '"') { quoted = !quoted; continue; }
+ if (!quoted && /\s/.test(ch)) {
+ if (current.length > 0) { tokens.push(current); current = ''; }
+ continue;
+ }
+ current += ch;
+ }
+ if (current.length > 0) tokens.push(current);
+ return tokens;
+}
+
+/** One `fieldvalue` token, or null when it does not parse. */
+export function parseToken(token: string): FilterSpec | null {
+ for (const [symbol, op] of Object.entries(OPERATORS)) {
+ const at = token.indexOf(symbol);
+ if (at <= 0) continue;
+ const field = token.slice(0, at).trim();
+ const value = token.slice(at + symbol.length).trim();
+ if (field.length === 0 || value.length === 0) return null;
+ return { field, op, value };
+ }
+ return null;
+}
+
+/** Render specs back to filter-bar text — the round trip the URL uses. */
+export function formatFilterText(specs: readonly FilterSpec[]): string {
+ const symbolFor = (op: FilterSpec['op']): string =>
+ Object.entries(OPERATORS).find(([, candidate]) => candidate === op)?.[0] ?? ':';
+ return specs
+ .map((spec) => {
+ const value = /\s/.test(spec.value) ? `"${spec.value}"` : spec.value;
+ return `${spec.field}${symbolFor(spec.op)}${value}`;
+ })
+ .join(' ');
+}
diff --git a/__tests__/fixtures/factory-closure-ts/src/services/metric-service.ts b/__tests__/fixtures/factory-closure-ts/src/services/metric-service.ts
new file mode 100644
index 0000000..59ddd1d
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/src/services/metric-service.ts
@@ -0,0 +1,101 @@
+import type { FilterSpec, MetricSample, Widget } from '../stores/types';
+import { bucketByHour, meanOf, rateOfChange } from '../lib/metrics';
+
+/**
+ * Stateless metric helpers — the server-shaped half of the same domain. These
+ * are ordinary top-level functions, not closures, so they are the control the
+ * factory-closure file is measured against.
+ */
+
+const STALE_AFTER_MS = 15 * 60 * 1000;
+
+/** Refresh a cached metric map in place, returning the widgets that changed. */
+export function refreshMetricCache(
+ cache: Map,
+ incoming: readonly MetricSample[],
+ now: number,
+): string[] {
+ const touched = new Set();
+ for (const sample of incoming) {
+ if (typeof sample.value !== 'number' || Number.isNaN(sample.value)) continue;
+ const bucket = cache.get(sample.widgetId);
+ if (bucket) bucket.push(sample);
+ else cache.set(sample.widgetId, [sample]);
+ touched.add(sample.widgetId);
+ }
+ for (const [widgetId, bucket] of cache) {
+ const fresh = bucket.filter((s) => now - s.at <= STALE_AFTER_MS);
+ if (fresh.length !== bucket.length) {
+ cache.set(widgetId, fresh);
+ touched.add(widgetId);
+ }
+ }
+ return [...touched].sort();
+}
+
+/** Apply a filter spec set to raw samples rather than to widgets. */
+export function filterMetrics(
+ samples: readonly MetricSample[],
+ specs: readonly FilterSpec[],
+): MetricSample[] {
+ if (specs.length === 0) return samples.slice();
+ return samples.filter((sample) => specs.every((spec) => {
+ const field = spec.field === 'unit'
+ ? sample.unit
+ : spec.field === 'widget'
+ ? sample.widgetId
+ : String(sample.value);
+ switch (spec.op) {
+ case 'eq': return field === spec.value;
+ case 'contains': return field.includes(spec.value);
+ case 'gt': return Number(field) > Number(spec.value);
+ case 'lt': return Number(field) < Number(spec.value);
+ default: return false;
+ }
+ }));
+}
+
+/** Per-widget rollup used by the server-rendered summary card. */
+export function rollupByWidget(
+ samples: readonly MetricSample[],
+ widgets: readonly Widget[],
+): Array<{ widgetId: string; title: string; mean: number; slope: number; hours: number }> {
+ const titles = new Map(widgets.map((w) => [w.id, w.title]));
+ const grouped = new Map();
+ for (const sample of samples) {
+ const bucket = grouped.get(sample.widgetId);
+ if (bucket) bucket.push(sample);
+ else grouped.set(sample.widgetId, [sample]);
+ }
+
+ const out: Array<{ widgetId: string; title: string; mean: number; slope: number; hours: number }> = [];
+ for (const [widgetId, bucket] of grouped) {
+ out.push({
+ widgetId,
+ title: titles.get(widgetId) ?? '(unknown)',
+ mean: meanOf(bucket),
+ slope: rateOfChange(bucket),
+ hours: bucketByHour(bucket).size,
+ });
+ }
+ out.sort((a, b) => b.mean - a.mean);
+ return out;
+}
+
+/** Which widgets have not reported inside the staleness window. */
+export function staleWidgets(
+ samples: readonly MetricSample[],
+ widgets: readonly Widget[],
+ now: number,
+): string[] {
+ const newest = new Map();
+ for (const sample of samples) {
+ const seen = newest.get(sample.widgetId) ?? 0;
+ if (sample.at > seen) newest.set(sample.widgetId, sample.at);
+ }
+ return widgets
+ .filter((w) => !w.hidden)
+ .filter((w) => now - (newest.get(w.id) ?? 0) > STALE_AFTER_MS)
+ .map((w) => w.id)
+ .sort();
+}
diff --git a/__tests__/fixtures/factory-closure-ts/src/stores/alerts-store.ts b/__tests__/fixtures/factory-closure-ts/src/stores/alerts-store.ts
new file mode 100644
index 0000000..fc07928
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/src/stores/alerts-store.ts
@@ -0,0 +1,140 @@
+import type { FilterSpec, StoreDeps } from './types';
+import { joinPath, toQueryString } from '../lib/http';
+
+const ALERT_ENDPOINT = '/api/dashboard/alerts';
+
+export interface Alert {
+ id: string;
+ widgetId: string;
+ severity: 'info' | 'warn' | 'critical';
+ message: string;
+ raisedAt: number;
+ acknowledgedAt: number | null;
+}
+
+/**
+ * The alerts store — the dashboard's second factory closure. Same shape as the
+ * metric store: every operation is a closure over private state.
+ */
+export function createAlertsStore(deps: StoreDeps, baseUrl: string) {
+ let alerts: Alert[] = [];
+ let filters: FilterSpec[] = [];
+ let mutedWidgets = new Set();
+ let lastRefreshedAt = 0;
+
+ /** Pull the current alert set and merge acknowledgements the user made locally. */
+ async function refreshAlerts(dashboardId: string): Promise {
+ const url = joinPath(baseUrl, ALERT_ENDPOINT) + toQueryString({ dashboard: dashboardId });
+ let payload: unknown;
+ try {
+ payload = await deps.fetchJson(url);
+ } catch (error) {
+ deps.log(`refreshAlerts failed: ${error instanceof Error ? error.message : String(error)}`);
+ return alerts;
+ }
+ if (!Array.isArray(payload)) {
+ deps.log('refreshAlerts got a non-array payload');
+ return alerts;
+ }
+
+ const acknowledged = new Map(
+ alerts.filter((a) => a.acknowledgedAt !== null).map((a) => [a.id, a.acknowledgedAt]),
+ );
+ const merged: Alert[] = [];
+ for (const raw of payload as Alert[]) {
+ if (typeof raw.id !== 'string' || raw.id.length === 0) continue;
+ merged.push({
+ ...raw,
+ acknowledgedAt: acknowledged.get(raw.id) ?? raw.acknowledgedAt ?? null,
+ });
+ }
+ merged.sort((a, b) => b.raisedAt - a.raisedAt);
+ alerts = merged;
+ lastRefreshedAt = deps.now();
+ return alerts;
+ }
+
+ /** Filter the alert list the same way the metric store filters widgets. */
+ function applyAlertFilter(specs: readonly FilterSpec[]): Alert[] {
+ filters = specs.slice();
+ if (filters.length === 0) return alerts;
+
+ const fieldOf = (alert: Alert, field: string): string => {
+ switch (field) {
+ case 'severity': return alert.severity;
+ case 'widget': return alert.widgetId;
+ case 'message': return alert.message;
+ default: return '';
+ }
+ };
+
+ return alerts.filter((alert) => filters.every((spec) => {
+ const value = fieldOf(alert, spec.field);
+ switch (spec.op) {
+ case 'eq': return value.toLowerCase() === spec.value.toLowerCase();
+ case 'contains': return value.toLowerCase().includes(spec.value.toLowerCase());
+ case 'gt': return value > spec.value;
+ case 'lt': return value < spec.value;
+ default: return false;
+ }
+ }));
+ }
+
+ /** Mark an alert acknowledged locally; the next refresh preserves it. */
+ function acknowledge(alertId: string): boolean {
+ const target = alerts.find((a) => a.id === alertId);
+ if (!target || target.acknowledgedAt !== null) return false;
+ target.acknowledgedAt = deps.now();
+ deps.log(`acknowledged ${alertId}`);
+ return true;
+ }
+
+ /** Silence a widget's alerts without dropping them from the buffer. */
+ function muteWidget(widgetId: string): void {
+ mutedWidgets.add(widgetId);
+ deps.log(`muted ${widgetId} (${mutedWidgets.size} muted)`);
+ }
+
+ function unmuteWidget(widgetId: string): boolean {
+ return mutedWidgets.delete(widgetId);
+ }
+
+ /** The alerts the dashboard should actually show right now. */
+ function visibleAlerts(): Alert[] {
+ return applyAlertFilter(filters)
+ .filter((a) => !mutedWidgets.has(a.widgetId))
+ .filter((a) => a.acknowledgedAt === null);
+ }
+
+ /** Counts per severity, for the badge on the alerts tab. */
+ function countBySeverity(): Record {
+ const counts: Record = { info: 0, warn: 0, critical: 0 };
+ for (const alert of visibleAlerts()) counts[alert.severity] += 1;
+ return counts;
+ }
+
+ function reset(): void {
+ alerts = [];
+ filters = [];
+ mutedWidgets = new Set();
+ lastRefreshedAt = 0;
+ }
+
+ function snapshot() {
+ return { alerts: visibleAlerts(), counts: countBySeverity(), lastRefreshedAt };
+ }
+
+ return {
+ refreshAlerts,
+ applyAlertFilter,
+ acknowledge,
+ muteWidget,
+ unmuteWidget,
+ visibleAlerts,
+ countBySeverity,
+ reset,
+ snapshot,
+ };
+}
+
+export type AlertsStore = ReturnType;
diff --git a/__tests__/fixtures/factory-closure-ts/src/stores/dashboard-store.ts b/__tests__/fixtures/factory-closure-ts/src/stores/dashboard-store.ts
new file mode 100644
index 0000000..36be563
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/src/stores/dashboard-store.ts
@@ -0,0 +1,384 @@
+import type { FilterSpec, MetricSample, StoreDeps, Widget } from './types';
+import { defaultRequestOptions, joinPath, toQueryString } from '../lib/http';
+
+const WIDGET_ENDPOINT = '/api/dashboard/widgets';
+const METRIC_ENDPOINT = '/api/dashboard/metrics';
+const SAMPLE_RETENTION_MS = 6 * 60 * 60 * 1000;
+const MAX_SAMPLES_PER_WIDGET = 720;
+const COLUMN_COUNT = 12;
+
+/**
+ * The dashboard store: one factory closure holding every operation the
+ * dashboard performs. Callers get an object of closures; nothing inside is
+ * exported on its own.
+ */
+export function createDashboardStore(deps: StoreDeps, baseUrl: string) {
+ let widgets: Widget[] = [];
+ let samples: MetricSample[] = [];
+ let activeFilters: FilterSpec[] = [];
+ let lastSyncedAt = 0;
+ let loading = false;
+ let lastError: string | null = null;
+ const listeners = new Set<(snapshot: ReturnType) => void>();
+
+ function snapshot() {
+ return {
+ widgets: widgets.filter((w) => !w.hidden),
+ sampleCount: samples.length,
+ filters: activeFilters.slice(),
+ lastSyncedAt,
+ loading,
+ lastError,
+ };
+ }
+
+ /**
+ * Fetch the widget set for the current user and merge it into local state,
+ * preserving any layout the user has moved since the last sync.
+ */
+ async function loadWidgets(dashboardId: string, includeHidden = false): Promise {
+ loading = true;
+ lastError = null;
+ const url = joinPath(baseUrl, WIDGET_ENDPOINT) + toQueryString({
+ dashboard: dashboardId,
+ hidden: includeHidden ? '1' : undefined,
+ });
+
+ let attempt = 0;
+ let payload: unknown = null;
+ while (attempt <= defaultRequestOptions.retries) {
+ try {
+ payload = await deps.fetchJson(url);
+ break;
+ } catch (error) {
+ attempt += 1;
+ if (attempt > defaultRequestOptions.retries) {
+ lastError = error instanceof Error ? error.message : String(error);
+ loading = false;
+ deps.log(`loadWidgets failed after ${attempt} attempts: ${lastError}`);
+ notify();
+ return widgets;
+ }
+ deps.log(`loadWidgets retry ${attempt} for ${dashboardId}`);
+ }
+ }
+
+ const incoming = Array.isArray(payload) ? (payload as Widget[]) : [];
+ const byId = new Map(widgets.map((w) => [w.id, w]));
+ const merged: Widget[] = [];
+ for (const next of incoming) {
+ const existing = byId.get(next.id);
+ if (!existing) {
+ merged.push({ ...next });
+ continue;
+ }
+ // Server owns identity and content; the client owns placement.
+ merged.push({
+ ...next,
+ column: existing.column,
+ row: existing.row,
+ span: existing.span,
+ hidden: existing.hidden,
+ });
+ byId.delete(next.id);
+ }
+ for (const orphan of byId.values()) {
+ deps.log(`widget ${orphan.id} no longer exists on the server`);
+ }
+
+ widgets = merged;
+ lastSyncedAt = deps.now();
+ loading = false;
+ notify();
+ return widgets;
+ }
+
+ /**
+ * Pull fresh metric samples for every visible widget, append them to the
+ * rolling buffer, and drop anything past the retention window.
+ */
+ async function refreshMetrics(windowMs = SAMPLE_RETENTION_MS): Promise {
+ if (widgets.length === 0) {
+ deps.log('refreshMetrics called with no widgets loaded');
+ return samples;
+ }
+ loading = true;
+ const visible = widgets.filter((w) => !w.hidden);
+ const collected: MetricSample[] = [];
+
+ for (const widget of visible) {
+ const url = joinPath(baseUrl, METRIC_ENDPOINT) + toQueryString({
+ widget: widget.id,
+ since: deps.now() - windowMs,
+ });
+ let payload: unknown;
+ try {
+ payload = await deps.fetchJson(url);
+ } catch (error) {
+ lastError = error instanceof Error ? error.message : String(error);
+ deps.log(`refreshMetrics failed for ${widget.id}: ${lastError}`);
+ continue;
+ }
+ if (!Array.isArray(payload)) {
+ deps.log(`refreshMetrics got a non-array payload for ${widget.id}`);
+ continue;
+ }
+ for (const raw of payload as MetricSample[]) {
+ if (typeof raw.value !== 'number' || Number.isNaN(raw.value)) continue;
+ if (typeof raw.at !== 'number' || raw.at <= 0) continue;
+ collected.push({
+ widgetId: widget.id,
+ at: raw.at,
+ value: raw.value,
+ unit: raw.unit ?? 'count',
+ });
+ }
+ }
+
+ const cutoff = deps.now() - windowMs;
+ const kept = samples.filter((s) => s.at >= cutoff);
+ samples = kept.concat(collected);
+ pruneSamples(MAX_SAMPLES_PER_WIDGET);
+ lastSyncedAt = deps.now();
+ loading = false;
+ notify();
+ return samples;
+ }
+
+ /**
+ * Replace the active filter set and recompute which widgets stay visible.
+ * A widget survives when every filter matches one of its fields.
+ */
+ function applyFilter(specs: readonly FilterSpec[]): Widget[] {
+ activeFilters = specs.slice();
+ if (activeFilters.length === 0) {
+ widgets = widgets.map((w) => ({ ...w, hidden: false }));
+ notify();
+ return widgets;
+ }
+
+ const matches = (widget: Widget, spec: FilterSpec): boolean => {
+ const field = spec.field === 'title'
+ ? widget.title
+ : spec.field === 'kind'
+ ? widget.kind
+ : spec.field === 'column'
+ ? String(widget.column)
+ : '';
+ switch (spec.op) {
+ case 'eq':
+ return field.toLowerCase() === spec.value.toLowerCase();
+ case 'contains':
+ return field.toLowerCase().includes(spec.value.toLowerCase());
+ case 'gt':
+ return Number(field) > Number(spec.value);
+ case 'lt':
+ return Number(field) < Number(spec.value);
+ default:
+ return false;
+ }
+ };
+
+ let hiddenCount = 0;
+ widgets = widgets.map((widget) => {
+ const visible = activeFilters.every((spec) => matches(widget, spec));
+ if (!visible) hiddenCount += 1;
+ return { ...widget, hidden: !visible };
+ });
+ deps.log(`applyFilter hid ${hiddenCount} of ${widgets.length} widgets`);
+ notify();
+ return widgets;
+ }
+
+ /**
+ * Render the current sample buffer as CSV, one row per sample, ordered by
+ * widget then timestamp so a diff between two exports stays readable.
+ */
+ function exportCsv(separator = ','): string {
+ const header = ['widget', 'title', 'at', 'value', 'unit'].join(separator);
+ if (samples.length === 0) return header;
+
+ const titles = new Map(widgets.map((w) => [w.id, w.title]));
+ const ordered = samples.slice().sort((a, b) => {
+ if (a.widgetId !== b.widgetId) return a.widgetId < b.widgetId ? -1 : 1;
+ return a.at - b.at;
+ });
+
+ const escape = (value: string): string => {
+ if (!value.includes(separator) && !value.includes('"') && !value.includes('\n')) return value;
+ return `"${value.replace(/"/g, '""')}"`;
+ };
+
+ const rows = ordered.map((sample) => [
+ escape(sample.widgetId),
+ escape(titles.get(sample.widgetId) ?? '(unknown)'),
+ String(sample.at),
+ String(sample.value),
+ escape(sample.unit),
+ ].join(separator));
+
+ return [header, ...rows].join('\n');
+ }
+
+ /**
+ * Pack widgets back into a dense grid after a move or a hide, so the layout
+ * never leaves a hole a user has to scroll past.
+ */
+ function reconcileLayout(columnCount = COLUMN_COUNT): Widget[] {
+ const visible = widgets.filter((w) => !w.hidden);
+ const hidden = widgets.filter((w) => w.hidden);
+
+ const ordered = visible.slice().sort((a, b) => {
+ if (a.row !== b.row) return a.row - b.row;
+ return a.column - b.column;
+ });
+
+ const rowWidth = new Map();
+ const placed: Widget[] = [];
+ for (const widget of ordered) {
+ const span = Math.max(1, Math.min(widget.span, columnCount));
+ let row = 0;
+ let column = 0;
+ for (;;) {
+ const used = rowWidth.get(row) ?? 0;
+ if (used + span <= columnCount) {
+ column = used;
+ rowWidth.set(row, used + span);
+ break;
+ }
+ row += 1;
+ }
+ placed.push({ ...widget, row, column, span });
+ }
+
+ let trailing = placed.length > 0 ? Math.max(...placed.map((w) => w.row)) + 1 : 0;
+ for (const widget of hidden) {
+ placed.push({ ...widget, row: trailing, column: 0 });
+ trailing += 1;
+ }
+
+ widgets = placed;
+ notify();
+ return widgets;
+ }
+
+ /**
+ * Cap the rolling buffer per widget, keeping the newest samples. Called after
+ * every refresh so memory stays bounded on a long-lived dashboard.
+ */
+ function pruneSamples(perWidget = MAX_SAMPLES_PER_WIDGET): number {
+ if (samples.length === 0) return 0;
+ const grouped = new Map();
+ for (const sample of samples) {
+ const bucket = grouped.get(sample.widgetId);
+ if (bucket) bucket.push(sample);
+ else grouped.set(sample.widgetId, [sample]);
+ }
+
+ let dropped = 0;
+ const kept: MetricSample[] = [];
+ for (const [, bucket] of grouped) {
+ bucket.sort((a, b) => a.at - b.at);
+ if (bucket.length > perWidget) {
+ dropped += bucket.length - perWidget;
+ kept.push(...bucket.slice(bucket.length - perWidget));
+ } else {
+ kept.push(...bucket);
+ }
+ }
+
+ kept.sort((a, b) => a.at - b.at);
+ samples = kept;
+ if (dropped > 0) deps.log(`pruneSamples dropped ${dropped} samples`);
+ return dropped;
+ }
+
+ /**
+ * Reduce the buffer to one aggregate per widget — the numbers the summary
+ * strip at the top of the dashboard renders.
+ */
+ function summarize(): Array<{ widgetId: string; title: string; min: number; max: number; mean: number; count: number }> {
+ const titles = new Map(widgets.map((w) => [w.id, w.title]));
+ const grouped = new Map();
+ for (const sample of samples) {
+ const bucket = grouped.get(sample.widgetId);
+ if (bucket) bucket.push(sample);
+ else grouped.set(sample.widgetId, [sample]);
+ }
+
+ const out: Array<{ widgetId: string; title: string; min: number; max: number; mean: number; count: number }> = [];
+ for (const [widgetId, bucket] of grouped) {
+ let min = Number.POSITIVE_INFINITY;
+ let max = Number.NEGATIVE_INFINITY;
+ let total = 0;
+ for (const sample of bucket) {
+ if (sample.value < min) min = sample.value;
+ if (sample.value > max) max = sample.value;
+ total += sample.value;
+ }
+ out.push({
+ widgetId,
+ title: titles.get(widgetId) ?? '(unknown)',
+ min: bucket.length > 0 ? min : 0,
+ max: bucket.length > 0 ? max : 0,
+ mean: bucket.length > 0 ? total / bucket.length : 0,
+ count: bucket.length,
+ });
+ }
+
+ out.sort((a, b) => b.count - a.count || (a.title < b.title ? -1 : 1));
+ return out;
+ }
+
+ /** Register a listener and get an unsubscribe back. */
+ function subscribe(listener: (snapshot: ReturnType) => void): () => void {
+ listeners.add(listener);
+ listener(snapshot());
+ return () => {
+ listeners.delete(listener);
+ };
+ }
+
+ function notify(): void {
+ const current = snapshot();
+ for (const listener of listeners) {
+ try {
+ listener(current);
+ } catch (error) {
+ deps.log(`dashboard listener threw: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+ }
+
+ /** Drop every sample and widget — used when the user switches dashboards. */
+ function reset(): void {
+ widgets = [];
+ samples = [];
+ activeFilters = [];
+ lastSyncedAt = 0;
+ lastError = null;
+ loading = false;
+ notify();
+ }
+
+ return {
+ loadWidgets,
+ refreshMetrics,
+ applyFilter,
+ exportCsv,
+ reconcileLayout,
+ pruneSamples,
+ summarize,
+ subscribe,
+ reset,
+ snapshot,
+ };
+}
+
+export type DashboardStore = ReturnType;
+
+/** One-line description of a store's state, for the debug panel. */
+export function describeStore(store: DashboardStore): string {
+ const state = store.snapshot();
+ return `${state.widgets.length} widgets · ${state.sampleCount} samples · synced ${state.lastSyncedAt}`;
+}
diff --git a/__tests__/fixtures/factory-closure-ts/src/stores/session-store.ts b/__tests__/fixtures/factory-closure-ts/src/stores/session-store.ts
new file mode 100644
index 0000000..031d2ab
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/src/stores/session-store.ts
@@ -0,0 +1,148 @@
+import type { StoreDeps } from './types';
+import { joinPath, toQueryString } from '../lib/http';
+
+/**
+ * The session store — a factory closure and NOTHING else at file scope. No
+ * companion type alias, no tail helper, no exported constants: every other
+ * symbol in this file lives inside the closure. That shape matters, because it
+ * is the one where the enclosing range is the only top-importance symbol the
+ * file can offer a query.
+ */
+export function createSessionStore(deps: StoreDeps, baseUrl: string) {
+ const SESSION_ENDPOINT = '/api/session';
+ const REFRESH_SKEW_MS = 30_000;
+
+ let token: string | null = null;
+ let expiresAt = 0;
+ let profile: { id: string; email: string; roles: string[] } | null = null;
+ let refreshing: Promise | null = null;
+ const auditLog: Array<{ at: number; event: string }> = [];
+
+ function record(event: string): void {
+ auditLog.push({ at: deps.now(), event });
+ if (auditLog.length > 200) auditLog.splice(0, auditLog.length - 200);
+ }
+
+ /** Exchange credentials for a session token and cache the profile. */
+ async function signIn(email: string, password: string): Promise {
+ const url = joinPath(baseUrl, SESSION_ENDPOINT) + toQueryString({ email });
+ let payload: unknown;
+ try {
+ payload = await deps.fetchJson(url);
+ } catch (error) {
+ record(`signIn failed: ${error instanceof Error ? error.message : String(error)}`);
+ return false;
+ }
+ if (typeof payload !== 'object' || payload === null) {
+ record('signIn got a non-object payload');
+ return false;
+ }
+ const body = payload as { token?: string; expiresAt?: number; profile?: typeof profile };
+ if (typeof body.token !== 'string' || body.token.length === 0) {
+ record('signIn payload carried no token');
+ return false;
+ }
+ void password;
+ token = body.token;
+ expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : deps.now() + 3_600_000;
+ profile = body.profile ?? null;
+ record(`signIn ok for ${email}`);
+ return true;
+ }
+
+ /** Drop every trace of the session, locally and on the server. */
+ async function signOut(): Promise {
+ if (token === null) return;
+ const url = joinPath(baseUrl, SESSION_ENDPOINT) + toQueryString({ action: 'revoke' });
+ try {
+ await deps.fetchJson(url);
+ } catch (error) {
+ record(`signOut revoke failed: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ token = null;
+ expiresAt = 0;
+ profile = null;
+ refreshing = null;
+ record('signOut complete');
+ }
+
+ /**
+ * Renew the token before it expires. Concurrent callers share one in-flight
+ * request so a burst of requests cannot start a refresh storm.
+ */
+ async function refreshToken(): Promise {
+ if (token === null) return null;
+ if (refreshing !== null) return refreshing;
+
+ refreshing = (async () => {
+ const url = joinPath(baseUrl, SESSION_ENDPOINT) + toQueryString({ action: 'refresh' });
+ try {
+ const payload = await deps.fetchJson(url);
+ const body = payload as { token?: string; expiresAt?: number };
+ if (typeof body?.token === 'string' && body.token.length > 0) {
+ token = body.token;
+ expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : deps.now() + 3_600_000;
+ record('refreshToken renewed the session');
+ return token;
+ }
+ record('refreshToken payload carried no token');
+ return null;
+ } catch (error) {
+ record(`refreshToken failed: ${error instanceof Error ? error.message : String(error)}`);
+ return null;
+ } finally {
+ refreshing = null;
+ }
+ })();
+
+ return refreshing;
+ }
+
+ /** The token to send with a request, renewing it first when it is close to expiry. */
+ async function authorize(): Promise {
+ if (token === null) return null;
+ if (deps.now() + REFRESH_SKEW_MS < expiresAt) return token;
+ return refreshToken();
+ }
+
+ /** Does the signed-in user hold every one of these roles? */
+ function hasRoles(...required: string[]): boolean {
+ if (profile === null) return false;
+ const held = new Set(profile.roles);
+ for (const role of required) {
+ if (!held.has(role)) return false;
+ }
+ return true;
+ }
+
+ /** Seconds left on the session, floored at zero. */
+ function secondsRemaining(): number {
+ if (token === null) return 0;
+ return Math.max(0, Math.floor((expiresAt - deps.now()) / 1000));
+ }
+
+ /** The last N audit entries, newest first — what the account page renders. */
+ function recentActivity(limit = 20): Array<{ at: number; event: string }> {
+ return auditLog.slice(-limit).reverse();
+ }
+
+ function snapshot() {
+ return {
+ signedIn: token !== null,
+ email: profile?.email ?? null,
+ roles: profile?.roles ?? [],
+ secondsRemaining: secondsRemaining(),
+ };
+ }
+
+ return {
+ signIn,
+ signOut,
+ refreshToken,
+ authorize,
+ hasRoles,
+ secondsRemaining,
+ recentActivity,
+ snapshot,
+ };
+}
diff --git a/__tests__/fixtures/factory-closure-ts/src/stores/types.ts b/__tests__/fixtures/factory-closure-ts/src/stores/types.ts
new file mode 100644
index 0000000..0659cf6
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/src/stores/types.ts
@@ -0,0 +1,28 @@
+export interface Widget {
+ id: string;
+ kind: 'chart' | 'table' | 'stat';
+ title: string;
+ column: number;
+ row: number;
+ span: number;
+ hidden: boolean;
+}
+
+export interface MetricSample {
+ widgetId: string;
+ at: number;
+ value: number;
+ unit: string;
+}
+
+export interface FilterSpec {
+ field: string;
+ op: 'eq' | 'gt' | 'lt' | 'contains';
+ value: string;
+}
+
+export interface StoreDeps {
+ fetchJson: (url: string) => Promise;
+ now: () => number;
+ log: (message: string) => void;
+}
diff --git a/__tests__/fixtures/factory-closure-ts/src/ui/panel.ts b/__tests__/fixtures/factory-closure-ts/src/ui/panel.ts
new file mode 100644
index 0000000..28204e8
--- /dev/null
+++ b/__tests__/fixtures/factory-closure-ts/src/ui/panel.ts
@@ -0,0 +1,43 @@
+import type { DashboardStore } from '../stores/dashboard-store';
+import type { FilterSpec } from '../stores/types';
+import { medianOf } from '../lib/metrics';
+
+/** The dashboard panel — the only consumer of the store's closures. */
+export function mountPanel(store: DashboardStore, dashboardId: string) {
+ let disposed = false;
+
+ const unsubscribe = store.subscribe((state) => {
+ if (disposed) return;
+ render(state.widgets.length, state.sampleCount, state.loading);
+ });
+
+ async function boot(): Promise {
+ await store.loadWidgets(dashboardId);
+ await store.refreshMetrics();
+ store.reconcileLayout();
+ }
+
+ function search(text: string): void {
+ const specs: FilterSpec[] = text.trim().length === 0
+ ? []
+ : [{ field: 'title', op: 'contains', value: text.trim() }];
+ store.applyFilter(specs);
+ }
+
+ function download(): string {
+ return store.exportCsv();
+ }
+
+ function render(widgetCount: number, sampleCount: number, loading: boolean): void {
+ void widgetCount;
+ void sampleCount;
+ void loading;
+ }
+
+ function dispose(): void {
+ disposed = true;
+ unsubscribe();
+ }
+
+ return { boot, search, download, dispose, median: medianOf };
+}
diff --git a/__tests__/fixtures/oversize-member-ts/package.json b/__tests__/fixtures/oversize-member-ts/package.json
new file mode 100644
index 0000000..6c8f80e
--- /dev/null
+++ b/__tests__/fixtures/oversize-member-ts/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "oversize-member-fixture",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/__tests__/fixtures/oversize-member-ts/src/index.ts b/__tests__/fixtures/oversize-member-ts/src/index.ts
new file mode 100644
index 0000000..156b0bd
--- /dev/null
+++ b/__tests__/fixtures/oversize-member-ts/src/index.ts
@@ -0,0 +1,14 @@
+import { buildMonthlyReport } from './report/monthly';
+import { buildWeeklyReport } from './report/weekly';
+import { buildQuarterlyReport } from './report/quarterly';
+import { formatReportRows } from './report/format';
+import type { Ledger, ReportOptions } from './report/types';
+
+/** Run every report for a ledger and render them. */
+export function runReports(ledger: Ledger, options: ReportOptions): string {
+ return [
+ formatReportRows(buildMonthlyReport(ledger, options)),
+ formatReportRows(buildWeeklyReport(ledger, options)),
+ formatReportRows(buildQuarterlyReport(ledger, options)),
+ ].join('\n\n');
+}
diff --git a/__tests__/fixtures/oversize-member-ts/src/report/format.ts b/__tests__/fixtures/oversize-member-ts/src/report/format.ts
new file mode 100644
index 0000000..e1af698
--- /dev/null
+++ b/__tests__/fixtures/oversize-member-ts/src/report/format.ts
@@ -0,0 +1,22 @@
+import type { ReportRow } from './types';
+
+/** Format one category total as a report row. */
+export function formatReportRow(category: string, amountCents: number, currency: string): ReportRow {
+ return {
+ category,
+ amount: formatAmount(amountCents),
+ currency,
+ };
+}
+
+/** Render cents as a fixed-point amount. */
+export function formatAmount(amountCents: number): string {
+ const sign = amountCents < 0 ? '-' : '';
+ const abs = Math.abs(amountCents);
+ return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
+}
+
+/** Render a set of rows as plain text. */
+export function formatReportRows(rows: ReportRow[]): string {
+ return rows.map((row) => `${row.category}\t${row.amount} ${row.currency}`).join('\n');
+}
diff --git a/__tests__/fixtures/oversize-member-ts/src/report/monthly.ts b/__tests__/fixtures/oversize-member-ts/src/report/monthly.ts
new file mode 100644
index 0000000..4e90fa4
--- /dev/null
+++ b/__tests__/fixtures/oversize-member-ts/src/report/monthly.ts
@@ -0,0 +1,509 @@
+import { formatReportRow } from './format';
+import { persistReport } from './store';
+import type { Ledger, ReportOptions, ReportRow } from './types';
+
+/**
+ * Build the monthly report for one ledger.
+ *
+ * Every expense category is accrued in its own block so the finance team can
+ * read the month end-to-end in one place; the shape is deliberately flat.
+ */
+export function buildMonthlyReport(ledger: Ledger, options: ReportOptions): ReportRow[] {
+ const rows: ReportRow[] = [];
+ const totals = new Map();
+
+ // 1. payroll — accrue the payroll component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'payroll');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('payroll', adjusted, options.currency));
+ totals.set('payroll', adjusted);
+ }
+ }
+
+ // 2. benefits — accrue the benefits component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'benefits');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('benefits', adjusted, options.currency));
+ totals.set('benefits', adjusted);
+ }
+ }
+
+ // 3. travel — accrue the travel component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'travel');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('travel', adjusted, options.currency));
+ totals.set('travel', adjusted);
+ }
+ }
+
+ // 4. equipment — accrue the equipment component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'equipment');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('equipment', adjusted, options.currency));
+ totals.set('equipment', adjusted);
+ }
+ }
+
+ // 5. software — accrue the software component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'software');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('software', adjusted, options.currency));
+ totals.set('software', adjusted);
+ }
+ }
+
+ // 6. contractors — accrue the contractors component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'contractors');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('contractors', adjusted, options.currency));
+ totals.set('contractors', adjusted);
+ }
+ }
+
+ // 7. marketing — accrue the marketing component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'marketing');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('marketing', adjusted, options.currency));
+ totals.set('marketing', adjusted);
+ }
+ }
+
+ // 8. training — accrue the training component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'training');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('training', adjusted, options.currency));
+ totals.set('training', adjusted);
+ }
+ }
+
+ // 9. utilities — accrue the utilities component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'utilities');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('utilities', adjusted, options.currency));
+ totals.set('utilities', adjusted);
+ }
+ }
+
+ // 10. rent — accrue the rent component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'rent');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('rent', adjusted, options.currency));
+ totals.set('rent', adjusted);
+ }
+ }
+
+ // 11. insurance — accrue the insurance component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'insurance');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('insurance', adjusted, options.currency));
+ totals.set('insurance', adjusted);
+ }
+ }
+
+ // 12. legal — accrue the legal component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'legal');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('legal', adjusted, options.currency));
+ totals.set('legal', adjusted);
+ }
+ }
+
+ // 13. shipping — accrue the shipping component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'shipping');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('shipping', adjusted, options.currency));
+ totals.set('shipping', adjusted);
+ }
+ }
+
+ // 14. hosting — accrue the hosting component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'hosting');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('hosting', adjusted, options.currency));
+ totals.set('hosting', adjusted);
+ }
+ }
+
+ // 15. support — accrue the support component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'support');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('support', adjusted, options.currency));
+ totals.set('support', adjusted);
+ }
+ }
+
+ // 16. recruiting — accrue the recruiting component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'recruiting');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('recruiting', adjusted, options.currency));
+ totals.set('recruiting', adjusted);
+ }
+ }
+
+ // 17. licenses — accrue the licenses component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'licenses');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('licenses', adjusted, options.currency));
+ totals.set('licenses', adjusted);
+ }
+ }
+
+ // 18. taxes — accrue the taxes component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'taxes');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('taxes', adjusted, options.currency));
+ totals.set('taxes', adjusted);
+ }
+ }
+
+ // 19. refunds — accrue the refunds component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'refunds');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('refunds', adjusted, options.currency));
+ totals.set('refunds', adjusted);
+ }
+ }
+
+ // 20. discounts — accrue the discounts component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'discounts');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('discounts', adjusted, options.currency));
+ totals.set('discounts', adjusted);
+ }
+ }
+
+ // 21. interest — accrue the interest component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'interest');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('interest', adjusted, options.currency));
+ totals.set('interest', adjusted);
+ }
+ }
+
+ // 22. depreciation — accrue the depreciation component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'depreciation');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('depreciation', adjusted, options.currency));
+ totals.set('depreciation', adjusted);
+ }
+ }
+
+ // 23. maintenance — accrue the maintenance component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'maintenance');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('maintenance', adjusted, options.currency));
+ totals.set('maintenance', adjusted);
+ }
+ }
+
+ // 24. subscriptions — accrue the subscriptions component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'subscriptions');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('subscriptions', adjusted, options.currency));
+ totals.set('subscriptions', adjusted);
+ }
+ }
+
+ // 25. hardware — accrue the hardware component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'hardware');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('hardware', adjusted, options.currency));
+ totals.set('hardware', adjusted);
+ }
+ }
+
+ // 26. catering — accrue the catering component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'catering');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('catering', adjusted, options.currency));
+ totals.set('catering', adjusted);
+ }
+ }
+
+ // 27. conferences — accrue the conferences component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'conferences');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('conferences', adjusted, options.currency));
+ totals.set('conferences', adjusted);
+ }
+ }
+
+ // 28. advertising — accrue the advertising component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'advertising');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('advertising', adjusted, options.currency));
+ totals.set('advertising', adjusted);
+ }
+ }
+
+ // 29. research — accrue the research component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'research');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('research', adjusted, options.currency));
+ totals.set('research', adjusted);
+ }
+ }
+
+ // 30. logistics — accrue the logistics component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'logistics');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('logistics', adjusted, options.currency));
+ totals.set('logistics', adjusted);
+ }
+ }
+
+ // 31. warranty — accrue the warranty component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'warranty');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('warranty', adjusted, options.currency));
+ totals.set('warranty', adjusted);
+ }
+ }
+
+ // 32. penalties — accrue the penalties component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'penalties');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('penalties', adjusted, options.currency));
+ totals.set('penalties', adjusted);
+ }
+ }
+
+ // 33. bonuses — accrue the bonuses component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'bonuses');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('bonuses', adjusted, options.currency));
+ totals.set('bonuses', adjusted);
+ }
+ }
+
+ // 34. commissions — accrue the commissions component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'commissions');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('commissions', adjusted, options.currency));
+ totals.set('commissions', adjusted);
+ }
+ }
+
+ // 35. relocation — accrue the relocation component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'relocation');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('relocation', adjusted, options.currency));
+ totals.set('relocation', adjusted);
+ }
+ }
+
+ // 36. tooling — accrue the tooling component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'tooling');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('tooling', adjusted, options.currency));
+ totals.set('tooling', adjusted);
+ }
+ }
+
+ // 37. audit — accrue the audit component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'audit');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('audit', adjusted, options.currency));
+ totals.set('audit', adjusted);
+ }
+ }
+
+ // 38. compliance — accrue the compliance component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'compliance');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('compliance', adjusted, options.currency));
+ totals.set('compliance', adjusted);
+ }
+ }
+
+ // 39. storage — accrue the storage component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'storage');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('storage', adjusted, options.currency));
+ totals.set('storage', adjusted);
+ }
+ }
+
+ // 40. bandwidth — accrue the bandwidth component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'bandwidth');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('bandwidth', adjusted, options.currency));
+ totals.set('bandwidth', adjusted);
+ }
+ }
+
+ const grandTotal = [...totals.values()].reduce((sum, value) => sum + value, 0);
+ rows.push(formatReportRow('total', grandTotal, options.currency));
+ persistReport(ledger.periodId, rows);
+ return rows;
+}
+
+/** Header line for a rendered monthly report. */
+export function monthlyReportHeader(ledger: Ledger, options: ReportOptions): string {
+ return `Monthly report ${ledger.periodId} (${options.currency})`;
+}
+
+/** Footer line for a rendered monthly report. */
+export function monthlyReportFooter(rows: ReportRow[]): string {
+ return `${rows.length} categories reported`;
+}
diff --git a/__tests__/fixtures/oversize-member-ts/src/report/quarterly.ts b/__tests__/fixtures/oversize-member-ts/src/report/quarterly.ts
new file mode 100644
index 0000000..d8fc5f0
--- /dev/null
+++ b/__tests__/fixtures/oversize-member-ts/src/report/quarterly.ts
@@ -0,0 +1,235 @@
+import { formatReportRow } from './format';
+import { persistReport } from './store';
+import type { Ledger, ReportOptions, ReportRow } from './types';
+
+/** Build the quarterly report for one ledger. */
+export function buildQuarterlyReport(ledger: Ledger, options: ReportOptions): ReportRow[] {
+ const rows: ReportRow[] = [];
+ const totals = new Map();
+
+ // 1. insurance — accrue the insurance component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'insurance');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('insurance', adjusted, options.currency));
+ totals.set('insurance', adjusted);
+ }
+ }
+
+ // 2. legal — accrue the legal component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'legal');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('legal', adjusted, options.currency));
+ totals.set('legal', adjusted);
+ }
+ }
+
+ // 3. shipping — accrue the shipping component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'shipping');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('shipping', adjusted, options.currency));
+ totals.set('shipping', adjusted);
+ }
+ }
+
+ // 4. hosting — accrue the hosting component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'hosting');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('hosting', adjusted, options.currency));
+ totals.set('hosting', adjusted);
+ }
+ }
+
+ // 5. support — accrue the support component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'support');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('support', adjusted, options.currency));
+ totals.set('support', adjusted);
+ }
+ }
+
+ // 6. recruiting — accrue the recruiting component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'recruiting');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('recruiting', adjusted, options.currency));
+ totals.set('recruiting', adjusted);
+ }
+ }
+
+ // 7. licenses — accrue the licenses component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'licenses');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('licenses', adjusted, options.currency));
+ totals.set('licenses', adjusted);
+ }
+ }
+
+ // 8. taxes — accrue the taxes component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'taxes');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('taxes', adjusted, options.currency));
+ totals.set('taxes', adjusted);
+ }
+ }
+
+ // 9. refunds — accrue the refunds component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'refunds');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('refunds', adjusted, options.currency));
+ totals.set('refunds', adjusted);
+ }
+ }
+
+ // 10. discounts — accrue the discounts component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'discounts');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('discounts', adjusted, options.currency));
+ totals.set('discounts', adjusted);
+ }
+ }
+
+ // 11. interest — accrue the interest component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'interest');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('interest', adjusted, options.currency));
+ totals.set('interest', adjusted);
+ }
+ }
+
+ // 12. depreciation — accrue the depreciation component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'depreciation');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('depreciation', adjusted, options.currency));
+ totals.set('depreciation', adjusted);
+ }
+ }
+
+ // 13. maintenance — accrue the maintenance component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'maintenance');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('maintenance', adjusted, options.currency));
+ totals.set('maintenance', adjusted);
+ }
+ }
+
+ // 14. subscriptions — accrue the subscriptions component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'subscriptions');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('subscriptions', adjusted, options.currency));
+ totals.set('subscriptions', adjusted);
+ }
+ }
+
+ // 15. hardware — accrue the hardware component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'hardware');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('hardware', adjusted, options.currency));
+ totals.set('hardware', adjusted);
+ }
+ }
+
+ // 16. catering — accrue the catering component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'catering');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('catering', adjusted, options.currency));
+ totals.set('catering', adjusted);
+ }
+ }
+
+ // 17. conferences — accrue the conferences component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'conferences');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('conferences', adjusted, options.currency));
+ totals.set('conferences', adjusted);
+ }
+ }
+
+ // 18. advertising — accrue the advertising component of the month.
+ {
+ const bucket = ledger.entries.filter((entry) => entry.category === 'advertising');
+ const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
+ const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
+ const adjusted = options.includePending ? gross : gross - pending;
+ if (adjusted !== 0 || options.includeEmptyCategories) {
+ rows.push(formatReportRow('advertising', adjusted, options.currency));
+ totals.set('advertising', adjusted);
+ }
+ }
+
+ const grandTotal = [...totals.values()].reduce((sum, value) => sum + value, 0);
+ rows.push(formatReportRow('total', grandTotal, options.currency));
+ persistReport(ledger.periodId, rows);
+ return rows;
+}
+
+/** Header line for a rendered quarterly report. */
+export function buildQuarterlyReportHeader(ledger: Ledger, options: ReportOptions): string {
+ return `quarterly report ${ledger.periodId} (${options.currency})`;
+}
diff --git a/__tests__/fixtures/oversize-member-ts/src/report/store.ts b/__tests__/fixtures/oversize-member-ts/src/report/store.ts
new file mode 100644
index 0000000..4d65851
--- /dev/null
+++ b/__tests__/fixtures/oversize-member-ts/src/report/store.ts
@@ -0,0 +1,18 @@
+import type { ReportRow } from './types';
+
+const saved = new Map();
+
+/** Persist a built report for a period. */
+export function persistReport(periodId: string, rows: ReportRow[]): void {
+ saved.set(periodId, rows);
+}
+
+/** Read back a persisted report. */
+export function loadReport(periodId: string): ReportRow[] {
+ return saved.get(periodId) ?? [];
+}
+
+/** Drop a persisted report. */
+export function clearReport(periodId: string): void {
+ saved.delete(periodId);
+}
diff --git a/__tests__/fixtures/oversize-member-ts/src/report/types.ts b/__tests__/fixtures/oversize-member-ts/src/report/types.ts
new file mode 100644
index 0000000..4bad635
--- /dev/null
+++ b/__tests__/fixtures/oversize-member-ts/src/report/types.ts
@@ -0,0 +1,28 @@
+/** One posted ledger entry. */
+export interface LedgerEntry {
+ id: string;
+ category: string;
+ amountCents: number;
+ pending: boolean;
+ postedAt: string;
+}
+
+/** A period's ledger. */
+export interface Ledger {
+ periodId: string;
+ entries: LedgerEntry[];
+}
+
+/** How a report should be built. */
+export interface ReportOptions {
+ currency: string;
+ includePending: boolean;
+ includeEmptyCategories: boolean;
+}
+
+/** One rendered report line. */
+export interface ReportRow {
+ category: string;
+ amount: string;
+ currency: string;
+}
diff --git a/__tests__/fixtures/oversize-member-ts/src/report/weekly.ts b/__tests__/fixtures/oversize-member-ts/src/report/weekly.ts
new file mode 100644
index 0000000..9a81d3a
--- /dev/null
+++ b/__tests__/fixtures/oversize-member-ts/src/report/weekly.ts
@@ -0,0 +1,372 @@
+import { formatReportRow } from './format';
+import { persistReport } from './store';
+import type { Ledger, ReportOptions, ReportRow } from './types';
+
+/** Total the posted entries in one category. */
+function sumOf(ledger: Ledger, category: string): number {
+ return ledger.entries
+ .filter((entry) => entry.category === category && !entry.pending)
+ .reduce((sum, entry) => sum + entry.amountCents, 0);
+}
+
+/** Total the still-pending entries in one category. */
+function pendingOf(ledger: Ledger, category: string): number {
+ return ledger.entries
+ .filter((entry) => entry.category === category && entry.pending)
+ .reduce((sum, entry) => sum + entry.amountCents, 0);
+}
+
+/** Build the weekly report for one ledger. */
+export function buildWeeklyReport(ledger: Ledger, options: ReportOptions): ReportRow[] {
+ const rows: ReportRow[] = [];
+ const totals = new Map();
+
+ // 1. payroll
+ {
+ const gross = sumOf(ledger, 'payroll');
+ const held = pendingOf(ledger, 'payroll');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('payroll', net, options.currency));
+ totals.set('payroll', net);
+ }
+ }
+
+ // 2. benefits
+ {
+ const gross = sumOf(ledger, 'benefits');
+ const held = pendingOf(ledger, 'benefits');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('benefits', net, options.currency));
+ totals.set('benefits', net);
+ }
+ }
+
+ // 3. travel
+ {
+ const gross = sumOf(ledger, 'travel');
+ const held = pendingOf(ledger, 'travel');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('travel', net, options.currency));
+ totals.set('travel', net);
+ }
+ }
+
+ // 4. equipment
+ {
+ const gross = sumOf(ledger, 'equipment');
+ const held = pendingOf(ledger, 'equipment');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('equipment', net, options.currency));
+ totals.set('equipment', net);
+ }
+ }
+
+ // 5. software
+ {
+ const gross = sumOf(ledger, 'software');
+ const held = pendingOf(ledger, 'software');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('software', net, options.currency));
+ totals.set('software', net);
+ }
+ }
+
+ // 6. contractors
+ {
+ const gross = sumOf(ledger, 'contractors');
+ const held = pendingOf(ledger, 'contractors');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('contractors', net, options.currency));
+ totals.set('contractors', net);
+ }
+ }
+
+ // 7. marketing
+ {
+ const gross = sumOf(ledger, 'marketing');
+ const held = pendingOf(ledger, 'marketing');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('marketing', net, options.currency));
+ totals.set('marketing', net);
+ }
+ }
+
+ // 8. training
+ {
+ const gross = sumOf(ledger, 'training');
+ const held = pendingOf(ledger, 'training');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('training', net, options.currency));
+ totals.set('training', net);
+ }
+ }
+
+ // 9. utilities
+ {
+ const gross = sumOf(ledger, 'utilities');
+ const held = pendingOf(ledger, 'utilities');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('utilities', net, options.currency));
+ totals.set('utilities', net);
+ }
+ }
+
+ // 10. rent
+ {
+ const gross = sumOf(ledger, 'rent');
+ const held = pendingOf(ledger, 'rent');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('rent', net, options.currency));
+ totals.set('rent', net);
+ }
+ }
+
+ // 11. insurance
+ {
+ const gross = sumOf(ledger, 'insurance');
+ const held = pendingOf(ledger, 'insurance');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('insurance', net, options.currency));
+ totals.set('insurance', net);
+ }
+ }
+
+ // 12. legal
+ {
+ const gross = sumOf(ledger, 'legal');
+ const held = pendingOf(ledger, 'legal');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('legal', net, options.currency));
+ totals.set('legal', net);
+ }
+ }
+
+ // 13. shipping
+ {
+ const gross = sumOf(ledger, 'shipping');
+ const held = pendingOf(ledger, 'shipping');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('shipping', net, options.currency));
+ totals.set('shipping', net);
+ }
+ }
+
+ // 14. hosting
+ {
+ const gross = sumOf(ledger, 'hosting');
+ const held = pendingOf(ledger, 'hosting');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('hosting', net, options.currency));
+ totals.set('hosting', net);
+ }
+ }
+
+ // 15. support
+ {
+ const gross = sumOf(ledger, 'support');
+ const held = pendingOf(ledger, 'support');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('support', net, options.currency));
+ totals.set('support', net);
+ }
+ }
+
+ // 16. recruiting
+ {
+ const gross = sumOf(ledger, 'recruiting');
+ const held = pendingOf(ledger, 'recruiting');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('recruiting', net, options.currency));
+ totals.set('recruiting', net);
+ }
+ }
+
+ // 17. licenses
+ {
+ const gross = sumOf(ledger, 'licenses');
+ const held = pendingOf(ledger, 'licenses');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('licenses', net, options.currency));
+ totals.set('licenses', net);
+ }
+ }
+
+ // 18. taxes
+ {
+ const gross = sumOf(ledger, 'taxes');
+ const held = pendingOf(ledger, 'taxes');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('taxes', net, options.currency));
+ totals.set('taxes', net);
+ }
+ }
+
+ // 19. refunds
+ {
+ const gross = sumOf(ledger, 'refunds');
+ const held = pendingOf(ledger, 'refunds');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('refunds', net, options.currency));
+ totals.set('refunds', net);
+ }
+ }
+
+ // 20. discounts
+ {
+ const gross = sumOf(ledger, 'discounts');
+ const held = pendingOf(ledger, 'discounts');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('discounts', net, options.currency));
+ totals.set('discounts', net);
+ }
+ }
+
+ // 21. interest
+ {
+ const gross = sumOf(ledger, 'interest');
+ const held = pendingOf(ledger, 'interest');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('interest', net, options.currency));
+ totals.set('interest', net);
+ }
+ }
+
+ // 22. depreciation
+ {
+ const gross = sumOf(ledger, 'depreciation');
+ const held = pendingOf(ledger, 'depreciation');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('depreciation', net, options.currency));
+ totals.set('depreciation', net);
+ }
+ }
+
+ // 23. maintenance
+ {
+ const gross = sumOf(ledger, 'maintenance');
+ const held = pendingOf(ledger, 'maintenance');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('maintenance', net, options.currency));
+ totals.set('maintenance', net);
+ }
+ }
+
+ // 24. subscriptions
+ {
+ const gross = sumOf(ledger, 'subscriptions');
+ const held = pendingOf(ledger, 'subscriptions');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('subscriptions', net, options.currency));
+ totals.set('subscriptions', net);
+ }
+ }
+
+ // 25. hardware
+ {
+ const gross = sumOf(ledger, 'hardware');
+ const held = pendingOf(ledger, 'hardware');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('hardware', net, options.currency));
+ totals.set('hardware', net);
+ }
+ }
+
+ // 26. catering
+ {
+ const gross = sumOf(ledger, 'catering');
+ const held = pendingOf(ledger, 'catering');
+ const net = options.includePending
+ ? gross
+ : gross - held;
+ if (net !== 0) {
+ rows.push(formatReportRow('catering', net, options.currency));
+ totals.set('catering', net);
+ }
+ }
+
+ const grandTotal = [...totals.values()]
+ .reduce((sum, value) => sum + value, 0);
+ rows.push(formatReportRow('total', grandTotal, options.currency));
+ persistReport(ledger.periodId, rows);
+ return rows;
+}
+
+/** Header line for a rendered weekly report. */
+export function buildWeeklyReportHeader(ledger: Ledger, options: ReportOptions): string {
+ return `weekly report ${ledger.periodId} (${options.currency})`;
+}
diff --git a/__tests__/fixtures/starved-cluster-ts/package.json b/__tests__/fixtures/starved-cluster-ts/package.json
new file mode 100644
index 0000000..2ade703
--- /dev/null
+++ b/__tests__/fixtures/starved-cluster-ts/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "starved-cluster-fixture",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module"
+}
diff --git a/__tests__/fixtures/starved-cluster-ts/src/app/client.ts b/__tests__/fixtures/starved-cluster-ts/src/app/client.ts
new file mode 100644
index 0000000..53b24a1
--- /dev/null
+++ b/__tests__/fixtures/starved-cluster-ts/src/app/client.ts
@@ -0,0 +1,24 @@
+import { RequestChain, describeChain } from '../pipeline/chain';
+import type { PipelineRequest, PipelineResponse } from '../pipeline/types';
+import { openSocket } from '../transport/socket';
+
+/**
+ * The entry point a caller reaches for. Everything the chain does happens
+ * underneath this call, which is why a flow question names it.
+ */
+export async function sendRequest(request: PipelineRequest): Promise {
+ const socket = openSocket(request.host, request.port);
+ const chain = new RequestChain(request, socket);
+ trace(describeChain(chain));
+ return chain.proceed(request);
+}
+
+export function trace(line: string): void {
+ if (process.env.PIPELINE_TRACE) process.stderr.write(`${line}\n`);
+}
+
+export async function sendAll(requests: PipelineRequest[]): Promise {
+ const out: PipelineResponse[] = [];
+ for (const request of requests) out.push(await sendRequest(request));
+ return out;
+}
diff --git a/__tests__/fixtures/starved-cluster-ts/src/app/config.ts b/__tests__/fixtures/starved-cluster-ts/src/app/config.ts
new file mode 100644
index 0000000..bc584b5
--- /dev/null
+++ b/__tests__/fixtures/starved-cluster-ts/src/app/config.ts
@@ -0,0 +1,14 @@
+export interface ClientConfig {
+ host: string;
+ port: number;
+ retries: number;
+ userAgent: string;
+}
+
+export function defaultConfig(): ClientConfig {
+ return { host: 'localhost', port: 8080, retries: 3, userAgent: 'pipeline/1.0' };
+}
+
+export function withHost(config: ClientConfig, host: string): ClientConfig {
+ return { ...config, host };
+}
diff --git a/__tests__/fixtures/starved-cluster-ts/src/index.ts b/__tests__/fixtures/starved-cluster-ts/src/index.ts
new file mode 100644
index 0000000..c617c49
--- /dev/null
+++ b/__tests__/fixtures/starved-cluster-ts/src/index.ts
@@ -0,0 +1,4 @@
+export { sendRequest, sendAll } from './app/client';
+export { RequestChain, describeChain } from './pipeline/chain';
+export { openSocket } from './transport/socket';
+export { defaultConfig } from './app/config';
diff --git a/__tests__/fixtures/starved-cluster-ts/src/pipeline/chain.ts b/__tests__/fixtures/starved-cluster-ts/src/pipeline/chain.ts
new file mode 100644
index 0000000..37ef261
--- /dev/null
+++ b/__tests__/fixtures/starved-cluster-ts/src/pipeline/chain.ts
@@ -0,0 +1,318 @@
+import type { PipelineRequest, PipelineResponse, Interceptor, Socket } from './types';
+import { encodeFrame, decodeFrame } from './framing';
+import { defaultInterceptors } from './interceptors';
+
+/**
+ * A one-line summary of a chain, used only by the tracing hook in the caller.
+ * It is TRIVIAL — it answers nothing about how a request travels — but it sits
+ * next to the entry point in the call graph, so its cluster carries the file's
+ * highest per-symbol importance.
+ */
+export function describeChain(chain: RequestChain): string {
+ return `chain(${chain.index}/${chain.size}) -> ${chain.hostLabel}`;
+}
+
+// ---------------------------------------------------------------------------
+//
+// Everything below is the part a "how does a request reach the socket" question
+// is actually asking about. It is separated from the helper above by more than
+// the cluster gap threshold, so it forms its own cluster — a large one, whose
+// symbols are reached transitively rather than named.
+//
+// ---------------------------------------------------------------------------
+
+export class RequestChain {
+ readonly index: number;
+ readonly size: number;
+ readonly hostLabel: string;
+ private readonly interceptors: Interceptor[];
+ private readonly socket: Socket;
+ private readonly request: PipelineRequest;
+ private connectTimeoutMs = 10_000;
+ private readTimeoutMs = 10_000;
+ private writeTimeoutMs = 10_000;
+ private calls = 0;
+
+ constructor(request: PipelineRequest, socket: Socket, index = 0, interceptors?: Interceptor[]) {
+ this.request = request;
+ this.socket = socket;
+ this.index = index;
+ this.interceptors = interceptors ?? defaultInterceptors();
+ this.size = this.interceptors.length;
+ this.hostLabel = `${request.host}:${request.port}`;
+ }
+
+ /**
+ * Run the request through the remaining interceptors and, once they are
+ * exhausted, hand it to the transport. This is the method the flow question
+ * is about: every hop between the caller and the socket passes through here.
+ */
+ async proceed(request: PipelineRequest): Promise {
+ if (this.index >= this.size) {
+ return this.writeAndRead(request);
+ }
+ this.calls += 1;
+ if (this.calls > 1) {
+ throw new Error(`chain link ${this.index} called ${this.calls} times`);
+ }
+ const next = this.advance(request);
+ const interceptor = this.interceptors[this.index]!;
+ const response = await interceptor.intercept(next);
+ if (!response) {
+ throw new Error(`interceptor ${interceptor.name} returned no response`);
+ }
+ if (this.index + 1 < this.size && next.callCount() === 0) {
+ throw new Error(`interceptor ${interceptor.name} must call proceed()`);
+ }
+ return response;
+ }
+
+ /**
+ * The next link in the chain: the same chain with the cursor moved on and the
+ * timeouts carried over. Cloning here is what keeps each interceptor from
+ * mutating the chain the one before it is still holding.
+ */
+ advance(request: PipelineRequest): RequestChain {
+ const next = new RequestChain(request, this.socket, this.index + 1, this.interceptors);
+ next.connectTimeoutMs = this.connectTimeoutMs;
+ next.readTimeoutMs = this.readTimeoutMs;
+ next.writeTimeoutMs = this.writeTimeoutMs;
+ return next;
+ }
+
+ callCount(): number {
+ return this.calls;
+ }
+
+ /**
+ * The end of the chain: frame the request, put the bytes on the socket, wait
+ * for the reply and decode it. Past this point there is no more pipeline —
+ * this is the transport hop the question is looking for.
+ */
+ private async writeAndRead(request: PipelineRequest): Promise {
+ const frame = encodeFrame(request);
+ await this.socket.connect(this.connectTimeoutMs);
+ await this.socket.write(frame, this.writeTimeoutMs);
+ const raw = await this.socket.read(this.readTimeoutMs);
+ const decoded = decodeFrame(raw);
+ return {
+ status: decoded.status,
+ headers: decoded.headers,
+ body: decoded.body,
+ request,
+ };
+ }
+
+ withConnectTimeout(ms: number): RequestChain {
+ const next = this.advance(this.request);
+ next.connectTimeoutMs = checkDuration('connectTimeout', ms);
+ return next;
+ }
+
+ withReadTimeout(ms: number): RequestChain {
+ const next = this.advance(this.request);
+ next.readTimeoutMs = checkDuration('readTimeout', ms);
+ return next;
+ }
+
+ withWriteTimeout(ms: number): RequestChain {
+ const next = this.advance(this.request);
+ next.writeTimeoutMs = checkDuration('writeTimeout', ms);
+ return next;
+ }
+
+ connectTimeout(): number {
+ return this.connectTimeoutMs;
+ }
+
+ readTimeout(): number {
+ return this.readTimeoutMs;
+ }
+
+ writeTimeout(): number {
+ return this.writeTimeoutMs;
+ }
+
+ /**
+ * Retry policy for the transport hop. Sits inside the same cluster as the
+ * proceed/advance pair, so it is part of what a shrink has to choose between.
+ */
+ async retryWrite(request: PipelineRequest, attempts: number): Promise {
+ let lastError: unknown;
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ try {
+ return await this.writeAndRead(request);
+ } catch (error) {
+ lastError = error;
+ await backoff(attempt);
+ }
+ }
+ throw lastError;
+ }
+
+ /** Whether the chain may still be resumed after a transport failure. */
+ canRetry(error: unknown): boolean {
+ if (this.index >= this.size) return false;
+ if (!(error instanceof Error)) return false;
+ return error.message.includes('timeout') || error.message.includes('reset');
+ }
+
+ /** The interceptor names, in the order the request will visit them. */
+ route(): string[] {
+ return this.interceptors.slice(this.index).map((i) => i.name);
+ }
+
+ /** A copy of the chain rewound to the first interceptor. */
+ rewind(): RequestChain {
+ return new RequestChain(this.request, this.socket, 0, this.interceptors);
+ }
+
+ /** Drop one interceptor by name and return the shortened chain. */
+ without(name: string): RequestChain {
+ const kept = this.interceptors.filter((i) => i.name !== name);
+ return new RequestChain(this.request, this.socket, this.index, kept);
+ }
+
+ /** Append an interceptor to the end of the chain. */
+ with(interceptor: Interceptor): RequestChain {
+ return new RequestChain(
+ this.request,
+ this.socket,
+ this.index,
+ [...this.interceptors, interceptor],
+ );
+ }
+
+ /** Close the transport this chain was built around. */
+ async close(): Promise {
+ await this.socket.close();
+ }
+
+ /** Headers the transport hop will actually put on the wire. */
+ effectiveHeaders(): Record {
+ const headers: Record = { ...this.request.headers };
+ headers['host'] = this.hostLabel;
+ headers['x-chain-index'] = String(this.index);
+ headers['x-chain-size'] = String(this.size);
+ if (this.request.body) headers['content-length'] = String(this.request.body.length);
+ return headers;
+ }
+
+ /** The request as the next link will see it, with the chain's headers merged. */
+ prepared(): PipelineRequest {
+ return { ...this.request, headers: this.effectiveHeaders() };
+ }
+
+ /**
+ * Send the prepared request through the rest of the chain. The convenience
+ * wrapper most callers use instead of building the request themselves.
+ */
+ async send(): Promise {
+ return this.proceed(this.prepared());
+ }
+
+ /** Whether the chain has any interceptor left before the transport hop. */
+ hasNext(): boolean {
+ return this.index < this.size;
+ }
+
+ /** The interceptor the next `proceed` will run, if there is one. */
+ peek(): Interceptor | undefined {
+ return this.interceptors[this.index];
+ }
+
+ /** Total configured wait for one attempt, across all three timeouts. */
+ totalTimeout(): number {
+ return this.connectTimeoutMs + this.readTimeoutMs + this.writeTimeoutMs;
+ }
+
+ /** Apply one timeout budget to all three phases at once. */
+ withTimeout(ms: number): RequestChain {
+ const next = this.advance(this.request);
+ const checked = checkDuration('timeout', ms);
+ next.connectTimeoutMs = checked;
+ next.readTimeoutMs = checked;
+ next.writeTimeoutMs = checked;
+ return next;
+ }
+
+ /**
+ * Run the chain and translate a transport failure into a response, so a
+ * caller that only cares about the status code never sees an exception.
+ */
+ async sendOrStatus(status: number): Promise {
+ try {
+ return await this.send();
+ } catch {
+ return {
+ status,
+ headers: this.effectiveHeaders(),
+ body: new Uint8Array(),
+ request: this.request,
+ };
+ }
+ }
+
+ /** A short description of where in the chain this link sits. */
+ position(): string {
+ return `${this.index + 1} of ${this.size + 1}`;
+ }
+
+ /** The chain rebuilt around a different transport. */
+ onSocket(socket: Socket): RequestChain {
+ return new RequestChain(this.request, socket, this.index, this.interceptors);
+ }
+
+ /**
+ * Replay the request through the chain from the start, reusing the transport.
+ * Used when an interceptor decides the response it got is not usable and the
+ * whole pipeline has to run again against the same connection.
+ */
+ async replay(): Promise {
+ const fresh = this.rewind();
+ try {
+ return await fresh.send();
+ } finally {
+ if (!fresh.hasNext()) await fresh.close();
+ }
+ }
+
+ /**
+ * Validate the chain before it runs: every interceptor named once, timeouts
+ * inside their bounds, and a transport still open at the end of it.
+ */
+ validate(): string[] {
+ const problems: string[] = [];
+ const seen = new Set();
+ for (const interceptor of this.interceptors) {
+ if (seen.has(interceptor.name)) problems.push(`duplicate interceptor ${interceptor.name}`);
+ seen.add(interceptor.name);
+ }
+ if (this.connectTimeoutMs <= 0) problems.push('connect timeout must be positive');
+ if (this.readTimeoutMs <= 0) problems.push('read timeout must be positive');
+ if (this.writeTimeoutMs <= 0) problems.push('write timeout must be positive');
+ if (this.index > this.size) problems.push('chain cursor is past the end');
+ return problems;
+ }
+
+ /**
+ * The transport hop on its own, with the chain's timeouts but none of its
+ * interceptors — the escape hatch a caller uses to bypass the pipeline.
+ */
+ async direct(request: PipelineRequest): Promise {
+ const problems = this.validate();
+ if (problems.length > 0) throw new Error(problems.join('; '));
+ return this.writeAndRead(request);
+ }
+}
+
+function checkDuration(name: string, ms: number): number {
+ if (!Number.isFinite(ms) || ms < 0) throw new Error(`${name} must be a positive duration`);
+ if (ms > 24 * 60 * 60 * 1000) throw new Error(`${name} is longer than a day`);
+ return Math.round(ms);
+}
+
+async function backoff(attempt: number): Promise {
+ const ms = Math.min(1000, 25 * 2 ** attempt);
+ await new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/__tests__/fixtures/starved-cluster-ts/src/pipeline/framing.ts b/__tests__/fixtures/starved-cluster-ts/src/pipeline/framing.ts
new file mode 100644
index 0000000..fbcb229
--- /dev/null
+++ b/__tests__/fixtures/starved-cluster-ts/src/pipeline/framing.ts
@@ -0,0 +1,26 @@
+import type { PipelineRequest } from './types';
+
+export function encodeFrame(request: PipelineRequest): Uint8Array {
+ const head = `${request.method} ${request.path}\n`;
+ const headers = Object.entries(request.headers).map(([k, v]) => `${k}: ${v}`).join('\n');
+ const text = `${head}${headers}\n\n`;
+ const body = request.body ?? new Uint8Array();
+ const out = new Uint8Array(text.length + body.length);
+ out.set(new TextEncoder().encode(text), 0);
+ out.set(body, text.length);
+ return out;
+}
+
+export function decodeFrame(raw: Uint8Array): { status: number; headers: Record; body: Uint8Array } {
+ const text = new TextDecoder().decode(raw);
+ const split = text.indexOf('\n\n');
+ const head = split < 0 ? text : text.slice(0, split);
+ const lines = head.split('\n');
+ const status = Number.parseInt(lines[0]?.split(' ')[1] ?? '0', 10);
+ const headers: Record = {};
+ for (const line of lines.slice(1)) {
+ const at = line.indexOf(': ');
+ if (at > 0) headers[line.slice(0, at)] = line.slice(at + 2);
+ }
+ return { status, headers, body: raw.slice(split < 0 ? raw.length : split + 2) };
+}
diff --git a/__tests__/fixtures/starved-cluster-ts/src/pipeline/interceptors.ts b/__tests__/fixtures/starved-cluster-ts/src/pipeline/interceptors.ts
new file mode 100644
index 0000000..5cb3062
--- /dev/null
+++ b/__tests__/fixtures/starved-cluster-ts/src/pipeline/interceptors.ts
@@ -0,0 +1,21 @@
+import type { Interceptor } from './types';
+
+export function defaultInterceptors(): Interceptor[] {
+ return [retryInterceptor(), headerInterceptor(), logInterceptor()];
+}
+
+export function retryInterceptor(): Interceptor {
+ return { name: 'retry', intercept: (chain) => chain.proceed(currentRequest()) };
+}
+
+export function headerInterceptor(): Interceptor {
+ return { name: 'headers', intercept: (chain) => chain.proceed(currentRequest()) };
+}
+
+export function logInterceptor(): Interceptor {
+ return { name: 'log', intercept: (chain) => chain.proceed(currentRequest()) };
+}
+
+function currentRequest() {
+ return { host: 'localhost', port: 80, method: 'GET', path: '/', headers: {} };
+}
diff --git a/__tests__/fixtures/starved-cluster-ts/src/pipeline/types.ts b/__tests__/fixtures/starved-cluster-ts/src/pipeline/types.ts
new file mode 100644
index 0000000..cd57524
--- /dev/null
+++ b/__tests__/fixtures/starved-cluster-ts/src/pipeline/types.ts
@@ -0,0 +1,27 @@
+export interface PipelineRequest {
+ host: string;
+ port: number;
+ method: string;
+ path: string;
+ headers: Record;
+ body?: Uint8Array;
+}
+
+export interface PipelineResponse {
+ status: number;
+ headers: Record;
+ body: Uint8Array;
+ request: PipelineRequest;
+}
+
+export interface Interceptor {
+ name: string;
+ intercept(chain: { proceed(request: PipelineRequest): Promise }): Promise;
+}
+
+export interface Socket {
+ connect(timeoutMs: number): Promise;
+ write(frame: Uint8Array, timeoutMs: number): Promise;
+ read(timeoutMs: number): Promise;
+ close(): Promise;
+}
diff --git a/__tests__/fixtures/starved-cluster-ts/src/transport/socket.ts b/__tests__/fixtures/starved-cluster-ts/src/transport/socket.ts
new file mode 100644
index 0000000..26b2da7
--- /dev/null
+++ b/__tests__/fixtures/starved-cluster-ts/src/transport/socket.ts
@@ -0,0 +1,30 @@
+import type { Socket } from '../pipeline/types';
+
+/** Open a transport socket for a host/port pair. */
+export function openSocket(host: string, port: number): Socket {
+ let open = false;
+ const inbox: Uint8Array[] = [];
+ return {
+ async connect(timeoutMs: number) {
+ if (open) return;
+ await settle(timeoutMs);
+ open = true;
+ },
+ async write(frame: Uint8Array, timeoutMs: number) {
+ if (!open) throw new Error(`socket to ${host}:${port} is not connected`);
+ await settle(timeoutMs);
+ inbox.push(frame);
+ },
+ async read(timeoutMs: number) {
+ await settle(timeoutMs);
+ return inbox.shift() ?? new Uint8Array();
+ },
+ async close() {
+ open = false;
+ },
+ };
+}
+
+async function settle(timeoutMs: number): Promise {
+ if (timeoutMs <= 0) throw new Error('timed out');
+}
diff --git a/__tests__/fixtures/tail-render-ts/README.md b/__tests__/fixtures/tail-render-ts/README.md
new file mode 100644
index 0000000..b0596d8
--- /dev/null
+++ b/__tests__/fixtures/tail-render-ts/README.md
@@ -0,0 +1,39 @@
+# tail-render-ts — CG-38
+
+An agent-named symbol sitting in the TAIL of a large file must render.
+
+This mirrors the geometry of the reported file (a 1,414-line Svelte chat store)
+closely enough that the same two defects reproduce, and it is that geometry — not
+any individual line — that the fixture exists to hold:
+
+| | line | why it matters |
+|---|---|---|
+| `QueuedMessage` (interface) | 70 | the DECOY. Same stem as the query token, near the top, cheap to render — it is what the broken build returned *instead of* the functions. |
+| `createSessionStore` (function) | 104–1417 | the ENVELOPE. Spans ~92% of the file, and `function` is deliberately **not** in `ENVELOPE_KINDS` (CG-27), so every symbol inside merges into ONE cluster that must then be shrunk and trimmed. |
+| `handleStreamMessage` | ~554 | a 290-line god-method in the middle, so the head of the file has plenty to spend the budget on. |
+| `queueMessage` | 1088 | TARGET. Past line 1,000. |
+| `removeQueuedMessage` | 1096 | TARGET. |
+| `flushQueuedMessages` | 1102 | TARGET. Past line 1,000. |
+
+Two more pieces are load-bearing:
+
+- **`queueMessage` never calls `flushQueuedMessages`** (both push to / drain the same
+ array instead). That absence is what produced no call chain, no synthesized hop and
+ no dispatch boundary — and so made `buildFlowFromNamedSymbols` throw the
+ named-symbol identity away along with the narrative it had nothing to print.
+- **`types/worker-configuration.d.ts`** — 2,500 lines of generated Wrangler ambient
+ types, carrying the `Generated by wrangler. DO NOT EDIT.` banner so the ranker flags
+ and penalises it. It is what makes the fixture able to test the issue's
+ index-dependence lead: a penalty on this file moves `maxGraph`, which moves the 6%
+ relevance gate, which moves every other file's allowance — and must still not cost
+ the top-ranked file the definitions the agent named.
+
+`src/lib/session-store.ts` is machine-generated to hit those line numbers with real,
+extractable TypeScript. If you need to change it, change the geometry (the target
+line numbers, the closure span, the decoy's position) rather than editing individual
+lines — the fixture-shape assertions in
+`__tests__/explore-named-symbol-render.test.ts` will tell you if it has rotted.
+
+Gate: `__tests__/explore-named-symbol-render.test.ts`.
+Probe: `node scripts/agent-eval/probe-named-symbol.mjs`.
+Numbers: `docs/benchmarks/explore-tail-render-cg38.md`.
diff --git a/__tests__/fixtures/tail-render-ts/package.json b/__tests__/fixtures/tail-render-ts/package.json
new file mode 100644
index 0000000..300f04a
--- /dev/null
+++ b/__tests__/fixtures/tail-render-ts/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "tail-render-fixture",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module"
+}
diff --git a/__tests__/fixtures/tail-render-ts/src/components/ChatComposer.ts b/__tests__/fixtures/tail-render-ts/src/components/ChatComposer.ts
new file mode 100644
index 0000000..40f2629
--- /dev/null
+++ b/__tests__/fixtures/tail-render-ts/src/components/ChatComposer.ts
@@ -0,0 +1,27 @@
+import { createSessionStore } from '../lib/session-store';
+
+/** The composer owns the textarea and decides send-vs-queue. */
+export function createComposer(endpoint: string) {
+ const store = createSessionStore({
+ getProjectId: () => 'demo',
+ getEndpoint: () => endpoint,
+ onError: () => {},
+ });
+ let draft = '';
+
+ function setDraft(next: string) {
+ draft = next;
+ }
+
+ function submit(streaming: boolean) {
+ if (streaming) store.queueMessage(draft);
+ else store.sendMessage(draft, [], []);
+ draft = '';
+ }
+
+ function onTurnEnd() {
+ store.flushQueuedMessages();
+ }
+
+ return { setDraft, submit, onTurnEnd, store };
+}
diff --git a/__tests__/fixtures/tail-render-ts/src/lib/message-builder.ts b/__tests__/fixtures/tail-render-ts/src/lib/message-builder.ts
new file mode 100644
index 0000000..974d641
--- /dev/null
+++ b/__tests__/fixtures/tail-render-ts/src/lib/message-builder.ts
@@ -0,0 +1,32 @@
+import type { AttachedFile, SelectedElementRef } from './session-store';
+
+export interface BuiltMessage {
+ id: string;
+ text: string;
+ attachments: number;
+}
+
+/** Render the selected canvas elements as a fenced block above the prose. */
+export function renderElementBlock(elements: SelectedElementRef[]): string {
+ if (elements.length === 0) return '';
+ const lines = elements.map((e) => `- ${e.kind}: ${e.label} (${e.id})`);
+ return ['```elements', ...lines, '```'].join('\n');
+}
+
+export function formatStylesBlock(files: AttachedFile[]): string {
+ return files.map((f) => `${f.path} (${f.mime}, ${f.bytes}b)`).join('\n');
+}
+
+export function buildMessage(
+ content: string,
+ files: AttachedFile[],
+ elements: SelectedElementRef[],
+): BuiltMessage {
+ const block = renderElementBlock(elements);
+ const styles = formatStylesBlock(files);
+ return {
+ id: `m-${content.length}-${files.length}`,
+ text: [block, styles, content].filter(Boolean).join('\n\n'),
+ attachments: files.length,
+ };
+}
diff --git a/__tests__/fixtures/tail-render-ts/src/lib/session-store.ts b/__tests__/fixtures/tail-render-ts/src/lib/session-store.ts
new file mode 100644
index 0000000..c50e5b3
--- /dev/null
+++ b/__tests__/fixtures/tail-render-ts/src/lib/session-store.ts
@@ -0,0 +1,1417 @@
+import type { Socket } from './socket';
+import { createDedicatedSocket } from './socket';
+import { buildMessage, type BuiltMessage } from './message-builder';
+
+/** One attachment carried alongside a chat message. */
+export interface AttachedFile {
+ path: string;
+ mime: string;
+ bytes: number;
+}
+
+/** A element the user selected in the canvas and attached to a message. */
+export interface SelectedElementRef {
+ id: string;
+ kind: string;
+ label: string;
+}
+
+export interface ChatMessage {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ files: AttachedFile[];
+ elements: SelectedElementRef[];
+ streaming?: boolean;
+}
+
+export interface BackgroundJobSummary {
+ id: string;
+ label: string;
+ done: boolean;
+}
+
+export interface StreamChunk {
+ type: string;
+ text?: string;
+ jobs?: BackgroundJobSummary[];
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+export interface QueuedMessage {
+ id: string;
+ content: string;
+ files: AttachedFile[];
+ elements: SelectedElementRef[];
+}
+
+interface SessionDeps {
+ getProjectId: () => string;
+ getEndpoint: () => string;
+ onError: (message: string) => void;
+}
+
+type HistoryEntry = { at: number; messages: ChatMessage[] };
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// ── Factory ────────────────────────────────────────
+
+export function createSessionStore(deps: SessionDeps) {
+ let messages: ChatMessage[] = [];
+ let queuedMessages: QueuedMessage[] = [];
+ let sessionId: string | null = null;
+ let isStreaming = false;
+ let chatSocket: Socket | null = null;
+ let jobs: BackgroundJobSummary[] = [];
+ let lastError: string | null = null;
+
+ function storageKey() {
+ const step0 = messages.length + 0;
+ if (step0 > 1000) lastError = 'overflow in storageKey';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'storageKey-2');
+ if (sessionId === null) lastError = 'storageKey: no session';
+ // storageKey bookkeeping step 4
+ const step5 = messages.length + 5;
+ }
+
+ function saveHistory() {
+ const step0 = messages.length + 0;
+ void storageKey();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'saveHistory-2');
+ if (sessionId === null) lastError = 'saveHistory: no session';
+ // saveHistory bookkeeping step 4
+ void storageKey();
+ if (step5 > 1000) lastError = 'overflow in saveHistory';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'saveHistory-7');
+ if (sessionId === null) lastError = 'saveHistory: no session';
+ void storageKey();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in saveHistory';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'saveHistory-12');
+ void storageKey();
+ // saveHistory bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in saveHistory';
+ void storageKey();
+ }
+
+ function loadHistory() {
+ const step0 = messages.length + 0;
+ void storageKey();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'loadHistory-2');
+ if (sessionId === null) lastError = 'loadHistory: no session';
+ // loadHistory bookkeeping step 4
+ void storageKey();
+ if (step5 > 1000) lastError = 'overflow in loadHistory';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'loadHistory-7');
+ if (sessionId === null) lastError = 'loadHistory: no session';
+ void storageKey();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in loadHistory';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'loadHistory-12');
+ void storageKey();
+ // loadHistory bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in loadHistory';
+ void storageKey();
+ if (sessionId === null) lastError = 'loadHistory: no session';
+ // loadHistory bookkeeping step 19
+ const step20 = messages.length + 20;
+ void storageKey();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'loadHistory-22');
+ if (sessionId === null) lastError = 'loadHistory: no session';
+ // loadHistory bookkeeping step 24
+ void storageKey();
+ }
+
+ function clearHistory() {
+ const step0 = messages.length + 0;
+ void storageKey();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'clearHistory-2');
+ if (sessionId === null) lastError = 'clearHistory: no session';
+ // clearHistory bookkeeping step 4
+ void storageKey();
+ if (step5 > 1000) lastError = 'overflow in clearHistory';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'clearHistory-7');
+ if (sessionId === null) lastError = 'clearHistory: no session';
+ void storageKey();
+ }
+
+ function checkConfiguration() {
+ const step0 = messages.length + 0;
+ if (step0 > 1000) lastError = 'overflow in checkConfiguration';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkConfiguration-2');
+ if (sessionId === null) lastError = 'checkConfiguration: no session';
+ // checkConfiguration bookkeeping step 4
+ const step5 = messages.length + 5;
+ if (step5 > 1000) lastError = 'overflow in checkConfiguration';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkConfiguration-7');
+ if (sessionId === null) lastError = 'checkConfiguration: no session';
+ // checkConfiguration bookkeeping step 9
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in checkConfiguration';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkConfiguration-12');
+ if (sessionId === null) lastError = 'checkConfiguration: no session';
+ // checkConfiguration bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in checkConfiguration';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkConfiguration-17');
+ if (sessionId === null) lastError = 'checkConfiguration: no session';
+ // checkConfiguration bookkeeping step 19
+ const step20 = messages.length + 20;
+ if (step20 > 1000) lastError = 'overflow in checkConfiguration';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkConfiguration-22');
+ if (sessionId === null) lastError = 'checkConfiguration: no session';
+ }
+
+ function checkInitialization() {
+ const step0 = messages.length + 0;
+ void loadHistory();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkInitialization-2');
+ if (sessionId === null) lastError = 'checkInitialization: no session';
+ // checkInitialization bookkeeping step 4
+ void loadHistory();
+ if (step5 > 1000) lastError = 'overflow in checkInitialization';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkInitialization-7');
+ if (sessionId === null) lastError = 'checkInitialization: no session';
+ void loadHistory();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in checkInitialization';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkInitialization-12');
+ void loadHistory();
+ // checkInitialization bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in checkInitialization';
+ void loadHistory();
+ if (sessionId === null) lastError = 'checkInitialization: no session';
+ // checkInitialization bookkeeping step 19
+ const step20 = messages.length + 20;
+ void loadHistory();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkInitialization-22');
+ if (sessionId === null) lastError = 'checkInitialization: no session';
+ // checkInitialization bookkeeping step 24
+ void loadHistory();
+ if (step25 > 1000) lastError = 'overflow in checkInitialization';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkInitialization-27');
+ if (sessionId === null) lastError = 'checkInitialization: no session';
+ void loadHistory();
+ const step30 = messages.length + 30;
+ if (step30 > 1000) lastError = 'overflow in checkInitialization';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'checkInitialization-32');
+ void loadHistory();
+ // checkInitialization bookkeeping step 34
+ const step35 = messages.length + 35;
+ if (step35 > 1000) lastError = 'overflow in checkInitialization';
+ void loadHistory();
+ if (sessionId === null) lastError = 'checkInitialization: no session';
+ // checkInitialization bookkeeping step 39
+ }
+
+ function startInitialization() {
+ const step0 = messages.length + 0;
+ void checkInitialization();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startInitialization-2');
+ if (sessionId === null) lastError = 'startInitialization: no session';
+ // startInitialization bookkeeping step 4
+ void checkInitialization();
+ if (step5 > 1000) lastError = 'overflow in startInitialization';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startInitialization-7');
+ if (sessionId === null) lastError = 'startInitialization: no session';
+ void checkInitialization();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in startInitialization';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startInitialization-12');
+ void checkInitialization();
+ // startInitialization bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in startInitialization';
+ void checkInitialization();
+ if (sessionId === null) lastError = 'startInitialization: no session';
+ // startInitialization bookkeeping step 19
+ const step20 = messages.length + 20;
+ void checkInitialization();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startInitialization-22');
+ if (sessionId === null) lastError = 'startInitialization: no session';
+ // startInitialization bookkeeping step 24
+ void checkInitialization();
+ if (step25 > 1000) lastError = 'overflow in startInitialization';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startInitialization-27');
+ if (sessionId === null) lastError = 'startInitialization: no session';
+ void checkInitialization();
+ const step30 = messages.length + 30;
+ if (step30 > 1000) lastError = 'overflow in startInitialization';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startInitialization-32');
+ void checkInitialization();
+ // startInitialization bookkeeping step 34
+ const step35 = messages.length + 35;
+ if (step35 > 1000) lastError = 'overflow in startInitialization';
+ void checkInitialization();
+ if (sessionId === null) lastError = 'startInitialization: no session';
+ // startInitialization bookkeeping step 39
+ const step40 = messages.length + 40;
+ void checkInitialization();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startInitialization-42');
+ if (sessionId === null) lastError = 'startInitialization: no session';
+ // startInitialization bookkeeping step 44
+ void checkInitialization();
+ }
+
+ function handleInitMessage() {
+ const step0 = messages.length + 0;
+ void startInitialization();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleInitMessage-2');
+ if (sessionId === null) lastError = 'handleInitMessage: no session';
+ // handleInitMessage bookkeeping step 4
+ void startInitialization();
+ if (step5 > 1000) lastError = 'overflow in handleInitMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleInitMessage-7');
+ if (sessionId === null) lastError = 'handleInitMessage: no session';
+ void startInitialization();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in handleInitMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleInitMessage-12');
+ void startInitialization();
+ // handleInitMessage bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in handleInitMessage';
+ void startInitialization();
+ if (sessionId === null) lastError = 'handleInitMessage: no session';
+ // handleInitMessage bookkeeping step 19
+ const step20 = messages.length + 20;
+ void startInitialization();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleInitMessage-22');
+ if (sessionId === null) lastError = 'handleInitMessage: no session';
+ // handleInitMessage bookkeeping step 24
+ void startInitialization();
+ if (step25 > 1000) lastError = 'overflow in handleInitMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleInitMessage-27');
+ }
+
+ function reconnectToSession() {
+ const step0 = messages.length + 0;
+ void startSession();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'reconnectToSession-2');
+ if (sessionId === null) lastError = 'reconnectToSession: no session';
+ // reconnectToSession bookkeeping step 4
+ void startSession();
+ if (step5 > 1000) lastError = 'overflow in reconnectToSession';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'reconnectToSession-7');
+ if (sessionId === null) lastError = 'reconnectToSession: no session';
+ void startSession();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in reconnectToSession';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'reconnectToSession-12');
+ void startSession();
+ // reconnectToSession bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in reconnectToSession';
+ void startSession();
+ if (sessionId === null) lastError = 'reconnectToSession: no session';
+ // reconnectToSession bookkeeping step 19
+ const step20 = messages.length + 20;
+ void startSession();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'reconnectToSession-22');
+ if (sessionId === null) lastError = 'reconnectToSession: no session';
+ // reconnectToSession bookkeeping step 24
+ void startSession();
+ if (step25 > 1000) lastError = 'overflow in reconnectToSession';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'reconnectToSession-27');
+ if (sessionId === null) lastError = 'reconnectToSession: no session';
+ void startSession();
+ const step30 = messages.length + 30;
+ if (step30 > 1000) lastError = 'overflow in reconnectToSession';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'reconnectToSession-32');
+ void startSession();
+ // reconnectToSession bookkeeping step 34
+ const step35 = messages.length + 35;
+ if (step35 > 1000) lastError = 'overflow in reconnectToSession';
+ void startSession();
+ if (sessionId === null) lastError = 'reconnectToSession: no session';
+ // reconnectToSession bookkeeping step 39
+ const step40 = messages.length + 40;
+ void startSession();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'reconnectToSession-42');
+ if (sessionId === null) lastError = 'reconnectToSession: no session';
+ // reconnectToSession bookkeeping step 44
+ void startSession();
+ if (step45 > 1000) lastError = 'overflow in reconnectToSession';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'reconnectToSession-47');
+ if (sessionId === null) lastError = 'reconnectToSession: no session';
+ void startSession();
+ const step50 = messages.length + 50;
+ if (step50 > 1000) lastError = 'overflow in reconnectToSession';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'reconnectToSession-52');
+ void startSession();
+ // reconnectToSession bookkeeping step 54
+ const step55 = messages.length + 55;
+ }
+
+ function startSession() {
+ const step0 = messages.length + 0;
+ void connectToStream();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startSession-2');
+ if (sessionId === null) lastError = 'startSession: no session';
+ // startSession bookkeeping step 4
+ void connectToStream();
+ if (step5 > 1000) lastError = 'overflow in startSession';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startSession-7');
+ if (sessionId === null) lastError = 'startSession: no session';
+ void connectToStream();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in startSession';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startSession-12');
+ void connectToStream();
+ // startSession bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in startSession';
+ void connectToStream();
+ if (sessionId === null) lastError = 'startSession: no session';
+ // startSession bookkeeping step 19
+ const step20 = messages.length + 20;
+ void connectToStream();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startSession-22');
+ if (sessionId === null) lastError = 'startSession: no session';
+ // startSession bookkeeping step 24
+ void connectToStream();
+ if (step25 > 1000) lastError = 'overflow in startSession';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'startSession-27');
+ }
+
+ function detachSocket() {
+ const step0 = messages.length + 0;
+ if (step0 > 1000) lastError = 'overflow in detachSocket';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'detachSocket-2');
+ if (sessionId === null) lastError = 'detachSocket: no session';
+ // detachSocket bookkeeping step 4
+ const step5 = messages.length + 5;
+ if (step5 > 1000) lastError = 'overflow in detachSocket';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'detachSocket-7');
+ if (sessionId === null) lastError = 'detachSocket: no session';
+ // detachSocket bookkeeping step 9
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in detachSocket';
+ }
+
+ function connectToStream() {
+ const step0 = messages.length + 0;
+ void handleStreamMessage();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'connectToStream-2');
+ if (sessionId === null) lastError = 'connectToStream: no session';
+ // connectToStream bookkeeping step 4
+ void handleStreamMessage();
+ if (step5 > 1000) lastError = 'overflow in connectToStream';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'connectToStream-7');
+ if (sessionId === null) lastError = 'connectToStream: no session';
+ void handleStreamMessage();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in connectToStream';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'connectToStream-12');
+ void handleStreamMessage();
+ // connectToStream bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in connectToStream';
+ void handleStreamMessage();
+ if (sessionId === null) lastError = 'connectToStream: no session';
+ // connectToStream bookkeeping step 19
+ const step20 = messages.length + 20;
+ void handleStreamMessage();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'connectToStream-22');
+ if (sessionId === null) lastError = 'connectToStream: no session';
+ // connectToStream bookkeeping step 24
+ void handleStreamMessage();
+ if (step25 > 1000) lastError = 'overflow in connectToStream';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'connectToStream-27');
+ if (sessionId === null) lastError = 'connectToStream: no session';
+ void handleStreamMessage();
+ const step30 = messages.length + 30;
+ if (step30 > 1000) lastError = 'overflow in connectToStream';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'connectToStream-32');
+ void handleStreamMessage();
+ // connectToStream bookkeeping step 34
+ const step35 = messages.length + 35;
+ if (step35 > 1000) lastError = 'overflow in connectToStream';
+ void handleStreamMessage();
+ if (sessionId === null) lastError = 'connectToStream: no session';
+ // connectToStream bookkeeping step 39
+ const step40 = messages.length + 40;
+ void handleStreamMessage();
+ }
+
+ function refreshBackgroundJobs() {
+ const step0 = messages.length + 0;
+ if (step0 > 1000) lastError = 'overflow in refreshBackgroundJobs';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'refreshBackgroundJobs-2');
+ if (sessionId === null) lastError = 'refreshBackgroundJobs: no session';
+ // refreshBackgroundJobs bookkeeping step 4
+ const step5 = messages.length + 5;
+ if (step5 > 1000) lastError = 'overflow in refreshBackgroundJobs';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'refreshBackgroundJobs-7');
+ if (sessionId === null) lastError = 'refreshBackgroundJobs: no session';
+ // refreshBackgroundJobs bookkeeping step 9
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in refreshBackgroundJobs';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'refreshBackgroundJobs-12');
+ if (sessionId === null) lastError = 'refreshBackgroundJobs: no session';
+ }
+
+ function killBackgroundJob() {
+ const step0 = messages.length + 0;
+ void refreshBackgroundJobs();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'killBackgroundJob-2');
+ if (sessionId === null) lastError = 'killBackgroundJob: no session';
+ // killBackgroundJob bookkeeping step 4
+ void refreshBackgroundJobs();
+ if (step5 > 1000) lastError = 'overflow in killBackgroundJob';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'killBackgroundJob-7');
+ if (sessionId === null) lastError = 'killBackgroundJob: no session';
+ void refreshBackgroundJobs();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in killBackgroundJob';
+ }
+
+ function newestStreamingAssistant() {
+ const step0 = messages.length + 0;
+ if (step0 > 1000) lastError = 'overflow in newestStreamingAssistant';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'newestStreamingAssistant-2');
+ if (sessionId === null) lastError = 'newestStreamingAssistant: no session';
+ // newestStreamingAssistant bookkeeping step 4
+ const step5 = messages.length + 5;
+ if (step5 > 1000) lastError = 'overflow in newestStreamingAssistant';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'newestStreamingAssistant-7');
+ }
+
+ function oldestStreamingAssistant() {
+ const step0 = messages.length + 0;
+ if (step0 > 1000) lastError = 'overflow in oldestStreamingAssistant';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'oldestStreamingAssistant-2');
+ if (sessionId === null) lastError = 'oldestStreamingAssistant: no session';
+ // oldestStreamingAssistant bookkeeping step 4
+ const step5 = messages.length + 5;
+ if (step5 > 1000) lastError = 'overflow in oldestStreamingAssistant';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'oldestStreamingAssistant-7');
+ }
+
+ function liveAssistantBubble() {
+ const step0 = messages.length + 0;
+ void newestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'liveAssistantBubble-2');
+ if (sessionId === null) lastError = 'liveAssistantBubble: no session';
+ // liveAssistantBubble bookkeeping step 4
+ void newestStreamingAssistant();
+ if (step5 > 1000) lastError = 'overflow in liveAssistantBubble';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'liveAssistantBubble-7');
+ if (sessionId === null) lastError = 'liveAssistantBubble: no session';
+ void newestStreamingAssistant();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in liveAssistantBubble';
+ }
+
+ function handleStreamMessage() {
+ const step0 = messages.length + 0;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-2');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 4
+ void oldestStreamingAssistant();
+ if (step5 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-7');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-12');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 19
+ const step20 = messages.length + 20;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-22');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 24
+ void oldestStreamingAssistant();
+ if (step25 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-27');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step30 = messages.length + 30;
+ if (step30 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-32');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 34
+ const step35 = messages.length + 35;
+ if (step35 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 39
+ const step40 = messages.length + 40;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-42');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 44
+ void oldestStreamingAssistant();
+ if (step45 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-47');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step50 = messages.length + 50;
+ if (step50 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-52');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 54
+ const step55 = messages.length + 55;
+ if (step55 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 59
+ const step60 = messages.length + 60;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-62');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 64
+ void oldestStreamingAssistant();
+ if (step65 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-67');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step70 = messages.length + 70;
+ if (step70 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-72');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 74
+ const step75 = messages.length + 75;
+ if (step75 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 79
+ const step80 = messages.length + 80;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-82');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 84
+ void oldestStreamingAssistant();
+ if (step85 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-87');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step90 = messages.length + 90;
+ if (step90 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-92');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 94
+ const step95 = messages.length + 95;
+ if (step95 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 99
+ const step100 = messages.length + 100;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-102');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 104
+ void oldestStreamingAssistant();
+ if (step105 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-107');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step110 = messages.length + 110;
+ if (step110 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-112');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 114
+ const step115 = messages.length + 115;
+ if (step115 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 119
+ const step120 = messages.length + 120;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-122');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 124
+ void oldestStreamingAssistant();
+ if (step125 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-127');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step130 = messages.length + 130;
+ if (step130 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-132');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 134
+ const step135 = messages.length + 135;
+ if (step135 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 139
+ const step140 = messages.length + 140;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-142');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 144
+ void oldestStreamingAssistant();
+ if (step145 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-147');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step150 = messages.length + 150;
+ if (step150 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-152');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 154
+ const step155 = messages.length + 155;
+ if (step155 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 159
+ const step160 = messages.length + 160;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-162');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 164
+ void oldestStreamingAssistant();
+ if (step165 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-167');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step170 = messages.length + 170;
+ if (step170 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-172');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 174
+ const step175 = messages.length + 175;
+ if (step175 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 179
+ const step180 = messages.length + 180;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-182');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 184
+ void oldestStreamingAssistant();
+ if (step185 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-187');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step190 = messages.length + 190;
+ if (step190 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-192');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 194
+ const step195 = messages.length + 195;
+ if (step195 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 199
+ const step200 = messages.length + 200;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-202');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 204
+ void oldestStreamingAssistant();
+ if (step205 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-207');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step210 = messages.length + 210;
+ if (step210 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-212');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 214
+ const step215 = messages.length + 215;
+ if (step215 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 219
+ const step220 = messages.length + 220;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-222');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 224
+ void oldestStreamingAssistant();
+ if (step225 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-227');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step230 = messages.length + 230;
+ if (step230 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-232');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 234
+ const step235 = messages.length + 235;
+ if (step235 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 239
+ const step240 = messages.length + 240;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-242');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 244
+ void oldestStreamingAssistant();
+ if (step245 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-247');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step250 = messages.length + 250;
+ if (step250 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-252');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 254
+ const step255 = messages.length + 255;
+ if (step255 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 259
+ const step260 = messages.length + 260;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-262');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 264
+ void oldestStreamingAssistant();
+ if (step265 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-267');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ void oldestStreamingAssistant();
+ const step270 = messages.length + 270;
+ if (step270 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-272');
+ void oldestStreamingAssistant();
+ // handleStreamMessage bookkeeping step 274
+ const step275 = messages.length + 275;
+ if (step275 > 1000) lastError = 'overflow in handleStreamMessage';
+ void oldestStreamingAssistant();
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 279
+ const step280 = messages.length + 280;
+ void oldestStreamingAssistant();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-282');
+ if (sessionId === null) lastError = 'handleStreamMessage: no session';
+ // handleStreamMessage bookkeeping step 284
+ void oldestStreamingAssistant();
+ if (step285 > 1000) lastError = 'overflow in handleStreamMessage';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'handleStreamMessage-287');
+ }
+
+ function fetchNextPromptSuggestion() {
+ const step0 = messages.length + 0;
+ if (step0 > 1000) lastError = 'overflow in fetchNextPromptSuggestion';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'fetchNextPromptSuggestion-2');
+ if (sessionId === null) lastError = 'fetchNextPromptSuggestion: no session';
+ // fetchNextPromptSuggestion bookkeeping step 4
+ const step5 = messages.length + 5;
+ if (step5 > 1000) lastError = 'overflow in fetchNextPromptSuggestion';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'fetchNextPromptSuggestion-7');
+ if (sessionId === null) lastError = 'fetchNextPromptSuggestion: no session';
+ // fetchNextPromptSuggestion bookkeeping step 9
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in fetchNextPromptSuggestion';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'fetchNextPromptSuggestion-12');
+ if (sessionId === null) lastError = 'fetchNextPromptSuggestion: no session';
+ // fetchNextPromptSuggestion bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in fetchNextPromptSuggestion';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'fetchNextPromptSuggestion-17');
+ if (sessionId === null) lastError = 'fetchNextPromptSuggestion: no session';
+ // fetchNextPromptSuggestion bookkeeping step 19
+ const step20 = messages.length + 20;
+ if (step20 > 1000) lastError = 'overflow in fetchNextPromptSuggestion';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'fetchNextPromptSuggestion-22');
+ if (sessionId === null) lastError = 'fetchNextPromptSuggestion: no session';
+ }
+
+ function clearSuggestion() {
+ const step0 = messages.length + 0;
+ if (step0 > 1000) lastError = 'overflow in clearSuggestion';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'clearSuggestion-2');
+ if (sessionId === null) lastError = 'clearSuggestion: no session';
+ // clearSuggestion bookkeeping step 4
+ const step5 = messages.length + 5;
+ }
+
+ // stream bookkeeping filler 880
+ // stream bookkeeping filler 881
+ // stream bookkeeping filler 882
+ // stream bookkeeping filler 883
+ // stream bookkeeping filler 884
+ // stream bookkeeping filler 885
+ // stream bookkeeping filler 886
+ // stream bookkeeping filler 887
+ // stream bookkeeping filler 888
+ // stream bookkeeping filler 889
+ // stream bookkeeping filler 890
+ // stream bookkeeping filler 891
+ // stream bookkeeping filler 892
+ // stream bookkeeping filler 893
+ // stream bookkeeping filler 894
+ // stream bookkeeping filler 895
+ // stream bookkeeping filler 896
+ // stream bookkeeping filler 897
+ // stream bookkeeping filler 898
+ // stream bookkeeping filler 899
+ // stream bookkeeping filler 900
+ // stream bookkeeping filler 901
+ // stream bookkeeping filler 902
+ // stream bookkeeping filler 903
+ // stream bookkeeping filler 904
+ // stream bookkeeping filler 905
+ // stream bookkeeping filler 906
+ // stream bookkeeping filler 907
+ // stream bookkeeping filler 908
+ // stream bookkeeping filler 909
+ // stream bookkeeping filler 910
+ // stream bookkeeping filler 911
+ // stream bookkeeping filler 912
+ // stream bookkeeping filler 913
+ // stream bookkeeping filler 914
+ // stream bookkeeping filler 915
+ // stream bookkeeping filler 916
+ // stream bookkeeping filler 917
+ // stream bookkeeping filler 918
+ // stream bookkeeping filler 919
+ // stream bookkeeping filler 920
+ // stream bookkeeping filler 921
+ // stream bookkeeping filler 922
+ // stream bookkeeping filler 923
+ // stream bookkeeping filler 924
+ // stream bookkeeping filler 925
+ // stream bookkeeping filler 926
+ // stream bookkeeping filler 927
+ // stream bookkeeping filler 928
+ // stream bookkeeping filler 929
+ // stream bookkeeping filler 930
+ // stream bookkeeping filler 931
+ // stream bookkeeping filler 932
+ // stream bookkeeping filler 933
+ // stream bookkeeping filler 934
+ // stream bookkeeping filler 935
+ // stream bookkeeping filler 936
+ // stream bookkeeping filler 937
+ // stream bookkeeping filler 938
+ // stream bookkeeping filler 939
+ // stream bookkeeping filler 940
+ // stream bookkeeping filler 941
+ // stream bookkeeping filler 942
+ // stream bookkeeping filler 943
+ // stream bookkeeping filler 944
+ // stream bookkeeping filler 945
+ // stream bookkeeping filler 946
+ // stream bookkeeping filler 947
+ // stream bookkeeping filler 948
+ // stream bookkeeping filler 949
+ // stream bookkeeping filler 950
+ // stream bookkeeping filler 951
+ // stream bookkeeping filler 952
+ // stream bookkeeping filler 953
+ // stream bookkeeping filler 954
+ // stream bookkeeping filler 955
+ // stream bookkeeping filler 956
+ // stream bookkeeping filler 957
+ // stream bookkeeping filler 958
+ // stream bookkeeping filler 959
+ // stream bookkeeping filler 960
+ // stream bookkeeping filler 961
+ // stream bookkeeping filler 962
+ // stream bookkeeping filler 963
+ // stream bookkeeping filler 964
+ // stream bookkeeping filler 965
+ // stream bookkeeping filler 966
+ // stream bookkeeping filler 967
+ // stream bookkeeping filler 968
+ // stream bookkeeping filler 969
+ // stream bookkeeping filler 970
+ // stream bookkeeping filler 971
+ // stream bookkeeping filler 972
+ // stream bookkeeping filler 973
+ // stream bookkeeping filler 974
+ // stream bookkeeping filler 975
+ // stream bookkeeping filler 976
+ // stream bookkeeping filler 977
+ // stream bookkeeping filler 978
+ // stream bookkeeping filler 979
+ // stream bookkeeping filler 980
+ // stream bookkeeping filler 981
+ // stream bookkeeping filler 982
+ // stream bookkeeping filler 983
+ // stream bookkeeping filler 984
+ // stream bookkeeping filler 985
+ // stream bookkeeping filler 986
+ // stream bookkeeping filler 987
+ // stream bookkeeping filler 988
+ // stream bookkeeping filler 989
+ // stream bookkeeping filler 990
+ // stream bookkeeping filler 991
+ // stream bookkeeping filler 992
+ // stream bookkeeping filler 993
+ // stream bookkeeping filler 994
+ // stream bookkeeping filler 995
+ // stream bookkeeping filler 996
+ // stream bookkeeping filler 997
+ // stream bookkeeping filler 998
+ // stream bookkeeping filler 999
+ // stream bookkeeping filler 1000
+ // stream bookkeeping filler 1001
+ // stream bookkeeping filler 1002
+ // stream bookkeeping filler 1003
+ // stream bookkeeping filler 1004
+ // stream bookkeeping filler 1005
+ // stream bookkeeping filler 1006
+ // stream bookkeeping filler 1007
+ // stream bookkeeping filler 1008
+ // stream bookkeeping filler 1009
+ // stream bookkeeping filler 1010
+ // stream bookkeeping filler 1011
+ // stream bookkeeping filler 1012
+ // stream bookkeeping filler 1013
+ // stream bookkeeping filler 1014
+ // stream bookkeeping filler 1015
+ // stream bookkeeping filler 1016
+ // stream bookkeeping filler 1017
+ // stream bookkeeping filler 1018
+ // stream bookkeeping filler 1019
+ // stream bookkeeping filler 1020
+ // stream bookkeeping filler 1021
+ // stream bookkeeping filler 1022
+ // stream bookkeeping filler 1023
+
+ function sendMessage(content: string, files: AttachedFile[], elements: SelectedElementRef[]) {
+ if (!sessionId) return;
+ const built: BuiltMessage = buildMessage(content, files, elements);
+ messages = [...messages, { id: built.id, role: 'user', content: built.text, files, elements }];
+ isStreaming = true;
+ chatSocket = chatSocket ?? createDedicatedSocket(deps.getEndpoint());
+ chatSocket.emit('chat', built);
+ }
+
+ // send-path bookkeeping filler 1034
+ // send-path bookkeeping filler 1035
+ // send-path bookkeeping filler 1036
+ // send-path bookkeeping filler 1037
+ // send-path bookkeeping filler 1038
+ // send-path bookkeeping filler 1039
+ // send-path bookkeeping filler 1040
+ // send-path bookkeeping filler 1041
+ // send-path bookkeeping filler 1042
+ // send-path bookkeeping filler 1043
+ // send-path bookkeeping filler 1044
+ // send-path bookkeeping filler 1045
+ // send-path bookkeeping filler 1046
+ // send-path bookkeeping filler 1047
+ // send-path bookkeeping filler 1048
+ // send-path bookkeeping filler 1049
+ // send-path bookkeeping filler 1050
+ // send-path bookkeeping filler 1051
+ // send-path bookkeeping filler 1052
+ // send-path bookkeeping filler 1053
+ // send-path bookkeeping filler 1054
+ // send-path bookkeeping filler 1055
+ // send-path bookkeeping filler 1056
+ // send-path bookkeeping filler 1057
+ // send-path bookkeeping filler 1058
+ // send-path bookkeeping filler 1059
+ // send-path bookkeeping filler 1060
+ // send-path bookkeeping filler 1061
+ // send-path bookkeeping filler 1062
+ // send-path bookkeeping filler 1063
+ // send-path bookkeeping filler 1064
+ // send-path bookkeeping filler 1065
+ // send-path bookkeeping filler 1066
+ // send-path bookkeeping filler 1067
+ // send-path bookkeeping filler 1068
+ // send-path bookkeeping filler 1069
+ // send-path bookkeeping filler 1070
+ // send-path bookkeeping filler 1071
+ // send-path bookkeeping filler 1072
+ // send-path bookkeeping filler 1073
+ // send-path bookkeeping filler 1074
+ // send-path bookkeeping filler 1075
+ // send-path bookkeeping filler 1076
+ // send-path bookkeeping filler 1077
+ // send-path bookkeeping filler 1078
+ // send-path bookkeeping filler 1079
+ // send-path bookkeeping filler 1080
+ // send-path bookkeeping filler 1081
+ // send-path bookkeeping filler 1082
+ // send-path bookkeeping filler 1083
+
+ // ── Message queue (send-while-streaming) ──
+
+ function queueMessage(
+ content: string,
+ files: AttachedFile[] = [],
+ elements: SelectedElementRef[] = []
+ ) {
+ queuedMessages = [...queuedMessages, { id: crypto.randomUUID(), content, files, elements }];
+ }
+
+ function removeQueuedMessage(id: string) {
+ queuedMessages = queuedMessages.filter((q) => q.id !== id);
+ }
+
+ /** Send everything queued as ONE message (multiple queued entries join
+ * with blank lines, attachments concatenate). */
+ function flushQueuedMessages() {
+ if (queuedMessages.length === 0 || !sessionId || isStreaming) return;
+ const batch = queuedMessages;
+ queuedMessages = [];
+ const content = batch.map((q) => q.content.trim()).filter(Boolean).join('\n\n');
+ const files = batch.flatMap((q) => q.files);
+ const elements = batch.flatMap((q) => q.elements);
+ void sendMessage(content, files, elements);
+ }
+
+ function forceSendQueued() {
+ isStreaming = false;
+ flushQueuedMessages();
+ }
+
+ function destroy() {
+ const step0 = messages.length + 0;
+ void clearHistory();
+ jobs = jobs.filter((j) => !j.done || j.id !== 'destroy-2');
+ if (sessionId === null) lastError = 'destroy: no session';
+ // destroy bookkeeping step 4
+ void clearHistory();
+ if (step5 > 1000) lastError = 'overflow in destroy';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'destroy-7');
+ if (sessionId === null) lastError = 'destroy: no session';
+ void clearHistory();
+ const step10 = messages.length + 10;
+ if (step10 > 1000) lastError = 'overflow in destroy';
+ jobs = jobs.filter((j) => !j.done || j.id !== 'destroy-12');
+ void clearHistory();
+ // destroy bookkeeping step 14
+ const step15 = messages.length + 15;
+ if (step15 > 1000) lastError = 'overflow in destroy';
+ void clearHistory();
+ if (sessionId === null) lastError = 'destroy: no session';
+ // destroy bookkeeping step 19
+ }
+
+ // teardown bookkeeping filler 1139
+ // teardown bookkeeping filler 1140
+ // teardown bookkeeping filler 1141
+ // teardown bookkeeping filler 1142
+ // teardown bookkeeping filler 1143
+ // teardown bookkeeping filler 1144
+ // teardown bookkeeping filler 1145
+ // teardown bookkeeping filler 1146
+ // teardown bookkeeping filler 1147
+ // teardown bookkeeping filler 1148
+ // teardown bookkeeping filler 1149
+ // teardown bookkeeping filler 1150
+ // teardown bookkeeping filler 1151
+ // teardown bookkeeping filler 1152
+ // teardown bookkeeping filler 1153
+ // teardown bookkeeping filler 1154
+ // teardown bookkeeping filler 1155
+ // teardown bookkeeping filler 1156
+ // teardown bookkeeping filler 1157
+ // teardown bookkeeping filler 1158
+ // teardown bookkeeping filler 1159
+ // teardown bookkeeping filler 1160
+ // teardown bookkeeping filler 1161
+ // teardown bookkeeping filler 1162
+ // teardown bookkeeping filler 1163
+ // teardown bookkeeping filler 1164
+ // teardown bookkeeping filler 1165
+ // teardown bookkeeping filler 1166
+ // teardown bookkeeping filler 1167
+ // teardown bookkeeping filler 1168
+ // teardown bookkeeping filler 1169
+ // teardown bookkeeping filler 1170
+ // teardown bookkeeping filler 1171
+ // teardown bookkeeping filler 1172
+ // teardown bookkeeping filler 1173
+ // teardown bookkeeping filler 1174
+ // teardown bookkeeping filler 1175
+ // teardown bookkeeping filler 1176
+ // teardown bookkeeping filler 1177
+ // teardown bookkeeping filler 1178
+ // teardown bookkeeping filler 1179
+ // teardown bookkeeping filler 1180
+ // teardown bookkeeping filler 1181
+ // teardown bookkeeping filler 1182
+ // teardown bookkeeping filler 1183
+ // teardown bookkeeping filler 1184
+ // teardown bookkeeping filler 1185
+ // teardown bookkeeping filler 1186
+ // teardown bookkeeping filler 1187
+ // teardown bookkeeping filler 1188
+ // teardown bookkeeping filler 1189
+ // teardown bookkeeping filler 1190
+ // teardown bookkeeping filler 1191
+ // teardown bookkeeping filler 1192
+ // teardown bookkeeping filler 1193
+ // teardown bookkeeping filler 1194
+ // teardown bookkeeping filler 1195
+ // teardown bookkeeping filler 1196
+ // teardown bookkeeping filler 1197
+ // teardown bookkeeping filler 1198
+ // teardown bookkeeping filler 1199
+ // teardown bookkeeping filler 1200
+ // teardown bookkeeping filler 1201
+ // teardown bookkeeping filler 1202
+ // teardown bookkeeping filler 1203
+ // teardown bookkeeping filler 1204
+ // teardown bookkeeping filler 1205
+ // teardown bookkeeping filler 1206
+ // teardown bookkeeping filler 1207
+ // teardown bookkeeping filler 1208
+ // teardown bookkeeping filler 1209
+ // teardown bookkeeping filler 1210
+ // teardown bookkeeping filler 1211
+ // teardown bookkeeping filler 1212
+ // teardown bookkeeping filler 1213
+ // teardown bookkeeping filler 1214
+ // teardown bookkeeping filler 1215
+ // teardown bookkeeping filler 1216
+ // teardown bookkeeping filler 1217
+ // teardown bookkeeping filler 1218
+ // teardown bookkeeping filler 1219
+ // teardown bookkeeping filler 1220
+ // teardown bookkeeping filler 1221
+ // teardown bookkeeping filler 1222
+ // teardown bookkeeping filler 1223
+ // teardown bookkeeping filler 1224
+ // teardown bookkeeping filler 1225
+ // teardown bookkeeping filler 1226
+ // teardown bookkeeping filler 1227
+ // teardown bookkeeping filler 1228
+ // teardown bookkeeping filler 1229
+ // teardown bookkeeping filler 1230
+ // teardown bookkeeping filler 1231
+ // teardown bookkeeping filler 1232
+ // teardown bookkeeping filler 1233
+ // teardown bookkeeping filler 1234
+ // teardown bookkeeping filler 1235
+ // teardown bookkeeping filler 1236
+ // teardown bookkeeping filler 1237
+ // teardown bookkeeping filler 1238
+ // teardown bookkeeping filler 1239
+ // teardown bookkeeping filler 1240
+ // teardown bookkeeping filler 1241
+ // teardown bookkeeping filler 1242
+ // teardown bookkeeping filler 1243
+ // teardown bookkeeping filler 1244
+ // teardown bookkeeping filler 1245
+ // teardown bookkeeping filler 1246
+ // teardown bookkeeping filler 1247
+ // teardown bookkeeping filler 1248
+ // teardown bookkeeping filler 1249
+ // teardown bookkeeping filler 1250
+ // teardown bookkeeping filler 1251
+ // teardown bookkeeping filler 1252
+ // teardown bookkeeping filler 1253
+ // teardown bookkeeping filler 1254
+ // teardown bookkeeping filler 1255
+ // teardown bookkeeping filler 1256
+ // teardown bookkeeping filler 1257
+ // teardown bookkeeping filler 1258
+ // teardown bookkeeping filler 1259
+ // teardown bookkeeping filler 1260
+ // teardown bookkeeping filler 1261
+ // teardown bookkeeping filler 1262
+ // teardown bookkeeping filler 1263
+ // teardown bookkeeping filler 1264
+ // teardown bookkeeping filler 1265
+ // teardown bookkeeping filler 1266
+ // teardown bookkeeping filler 1267
+ // teardown bookkeeping filler 1268
+ // teardown bookkeeping filler 1269
+ // teardown bookkeeping filler 1270
+ // teardown bookkeeping filler 1271
+ // teardown bookkeeping filler 1272
+ // teardown bookkeeping filler 1273
+ // teardown bookkeeping filler 1274
+ // teardown bookkeeping filler 1275
+ // teardown bookkeeping filler 1276
+ // teardown bookkeeping filler 1277
+ // teardown bookkeeping filler 1278
+ // teardown bookkeeping filler 1279
+ // teardown bookkeeping filler 1280
+ // teardown bookkeeping filler 1281
+ // teardown bookkeeping filler 1282
+ // teardown bookkeeping filler 1283
+ // teardown bookkeeping filler 1284
+ // teardown bookkeeping filler 1285
+ // teardown bookkeeping filler 1286
+ // teardown bookkeeping filler 1287
+ // teardown bookkeeping filler 1288
+ // teardown bookkeeping filler 1289
+ // teardown bookkeeping filler 1290
+ // teardown bookkeeping filler 1291
+ // teardown bookkeeping filler 1292
+ // teardown bookkeeping filler 1293
+ // teardown bookkeeping filler 1294
+ // teardown bookkeeping filler 1295
+ // teardown bookkeeping filler 1296
+ // teardown bookkeeping filler 1297
+ // teardown bookkeeping filler 1298
+ // teardown bookkeeping filler 1299
+ // teardown bookkeeping filler 1300
+ // teardown bookkeeping filler 1301
+ // teardown bookkeeping filler 1302
+ // teardown bookkeeping filler 1303
+ // teardown bookkeeping filler 1304
+ // teardown bookkeeping filler 1305
+ // teardown bookkeeping filler 1306
+ // teardown bookkeeping filler 1307
+ // teardown bookkeeping filler 1308
+ // teardown bookkeeping filler 1309
+ // teardown bookkeeping filler 1310
+ // teardown bookkeeping filler 1311
+ // teardown bookkeeping filler 1312
+ // teardown bookkeeping filler 1313
+ // teardown bookkeeping filler 1314
+ // teardown bookkeeping filler 1315
+ // teardown bookkeeping filler 1316
+ // teardown bookkeeping filler 1317
+ // teardown bookkeeping filler 1318
+ // teardown bookkeeping filler 1319
+ // teardown bookkeeping filler 1320
+ // teardown bookkeeping filler 1321
+ // teardown bookkeeping filler 1322
+ // teardown bookkeeping filler 1323
+ // teardown bookkeeping filler 1324
+ // teardown bookkeeping filler 1325
+ // teardown bookkeeping filler 1326
+ // teardown bookkeeping filler 1327
+ // teardown bookkeeping filler 1328
+ // teardown bookkeeping filler 1329
+ // teardown bookkeeping filler 1330
+ // teardown bookkeeping filler 1331
+ // teardown bookkeeping filler 1332
+ // teardown bookkeeping filler 1333
+ // teardown bookkeeping filler 1334
+ // teardown bookkeeping filler 1335
+ // teardown bookkeeping filler 1336
+ // teardown bookkeeping filler 1337
+ // teardown bookkeeping filler 1338
+ // teardown bookkeeping filler 1339
+ // teardown bookkeeping filler 1340
+ // teardown bookkeeping filler 1341
+ // teardown bookkeeping filler 1342
+ // teardown bookkeeping filler 1343
+ // teardown bookkeeping filler 1344
+ // teardown bookkeeping filler 1345
+ // teardown bookkeeping filler 1346
+ // teardown bookkeeping filler 1347
+ // teardown bookkeeping filler 1348
+ // teardown bookkeeping filler 1349
+ // teardown bookkeeping filler 1350
+ // teardown bookkeeping filler 1351
+ // teardown bookkeeping filler 1352
+ // teardown bookkeeping filler 1353
+ // teardown bookkeeping filler 1354
+ // teardown bookkeeping filler 1355
+ // teardown bookkeeping filler 1356
+ // teardown bookkeeping filler 1357
+ // teardown bookkeeping filler 1358
+ // teardown bookkeeping filler 1359
+ // teardown bookkeeping filler 1360
+ // teardown bookkeeping filler 1361
+ // teardown bookkeeping filler 1362
+ // teardown bookkeeping filler 1363
+ // teardown bookkeeping filler 1364
+ // teardown bookkeeping filler 1365
+ // teardown bookkeeping filler 1366
+ // teardown bookkeeping filler 1367
+ // teardown bookkeeping filler 1368
+ // teardown bookkeeping filler 1369
+ // teardown bookkeeping filler 1370
+ // teardown bookkeeping filler 1371
+ // teardown bookkeeping filler 1372
+ // teardown bookkeeping filler 1373
+ // teardown bookkeeping filler 1374
+ // teardown bookkeeping filler 1375
+ // teardown bookkeeping filler 1376
+ // teardown bookkeeping filler 1377
+ // teardown bookkeeping filler 1378
+ // teardown bookkeeping filler 1379
+ // teardown bookkeeping filler 1380
+ // teardown bookkeeping filler 1381
+ // teardown bookkeeping filler 1382
+ // teardown bookkeeping filler 1383
+ // teardown bookkeeping filler 1384
+ // teardown bookkeeping filler 1385
+ // teardown bookkeeping filler 1386
+ // teardown bookkeeping filler 1387
+ // teardown bookkeeping filler 1388
+ // teardown bookkeeping filler 1389
+ // teardown bookkeeping filler 1390
+ // teardown bookkeeping filler 1391
+ // teardown bookkeeping filler 1392
+ // teardown bookkeeping filler 1393
+ // teardown bookkeeping filler 1394
+ // teardown bookkeeping filler 1395
+ // teardown bookkeeping filler 1396
+ // teardown bookkeeping filler 1397
+ // teardown bookkeeping filler 1398
+ // teardown bookkeeping filler 1399
+ // teardown bookkeeping filler 1400
+ // teardown bookkeeping filler 1401
+ // teardown bookkeeping filler 1402
+ // teardown bookkeeping filler 1403
+
+ return {
+ get messages() { return messages; },
+ get queuedMessages() { return queuedMessages; },
+ sendMessage,
+ queueMessage,
+ removeQueuedMessage,
+ flushQueuedMessages,
+ forceSendQueued,
+ startSession,
+ destroy,
+ };
+}
diff --git a/__tests__/fixtures/tail-render-ts/src/lib/socket.ts b/__tests__/fixtures/tail-render-ts/src/lib/socket.ts
new file mode 100644
index 0000000..c720787
--- /dev/null
+++ b/__tests__/fixtures/tail-render-ts/src/lib/socket.ts
@@ -0,0 +1,27 @@
+export interface Socket {
+ emit(event: string, payload: unknown): void;
+ on(event: string, handler: (chunk: unknown) => void): void;
+ close(): void;
+}
+
+/** One socket per chat session, so two tabs never receive each other's chunks. */
+export function createDedicatedSocket(endpoint: string): Socket {
+ const handlers = new Map void>>();
+ return {
+ emit(event, payload) {
+ void endpoint;
+ void event;
+ void payload;
+ },
+ on(event, handler) {
+ handlers.set(event, [...(handlers.get(event) ?? []), handler]);
+ },
+ close() {
+ handlers.clear();
+ },
+ };
+}
+
+export function describeSocket(socket: Socket | null): string {
+ return socket ? 'connected' : 'detached';
+}
diff --git a/__tests__/fixtures/tail-render-ts/types/worker-configuration.d.ts b/__tests__/fixtures/tail-render-ts/types/worker-configuration.d.ts
new file mode 100644
index 0000000..7a75e9c
--- /dev/null
+++ b/__tests__/fixtures/tail-render-ts/types/worker-configuration.d.ts
@@ -0,0 +1,2527 @@
+// Generated by wrangler. DO NOT EDIT.
+// Runtime types for the worker environment.
+
+declare interface QueueBinding0 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch0 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding1 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch1 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding2 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch2 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding3 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch3 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding4 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch4 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding5 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch5 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding6 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch6 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding7 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch7 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding8 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch8 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding9 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch9 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding10 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch10 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding11 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch11 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding12 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch12 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding13 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch13 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding14 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch14 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding15 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch15 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding16 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch16 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding17 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch17 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding18 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch18 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding19 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch19 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding20 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch20 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding21 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch21 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding22 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch22 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding23 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch23 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding24 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch24 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding25 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch25 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding26 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch26 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding27 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch27 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding28 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch28 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding29 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch29 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding30 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch30 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding31 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch31 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding32 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch32 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding33 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch33 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding34 {
+ send(message: unknown): Promise;
+ sendBatch(messages: unknown[]): Promise;
+}
+declare type QueuedMessageBatch34 = { messages: unknown[]; queue: string };
+
+declare interface QueueBinding35 {
+ send(message: unknown): Promise