typescript-refactor-large-file-barrel-split
The 2152-line src/core/prompt-builder.ts was split into a self-contained prompt-builder/ package with a barrel entry, so all 10 external importers keep their existing import specifiers and behavior.
Target layout:
src/core/prompt-builder.ts → deleted (replaced by directory)
src/core/prompt-builder/
├── types.ts # shared interfaces/types — imports nothing at runtime
├── injection.ts # context-injection logic
├── ranks.ts # fragment rank/ordering logic
├── fragment-loader.ts # fragment loading (disk/config)
├── output-template.ts # output-template rendering
├── builder.ts # PromptBuilder class + promptBuilder singleton (defined ONCE)
└── index.ts # barrel re-exporting the exact previous public API
Because index.ts sits where prompt-builder.ts used to be, every existing importer such as import { promptBuilder, PromptBuilder } from "../core/prompt-builder.js" resolves to the same module instance as before — zero importer edits.
1. Singleton defined once in builder.ts, re-exported (never re-instantiated)
// src/core/prompt-builder/builder.ts
import type { PromptFragment, RankFragment, OutputTemplate } from "./types.js";
import { injectContext } from "./injection.js";
import { rankFragments } from "./ranks.js";
import { loadFragments } from "./fragment-loader.js";
import { renderOutput } from "./output-template.js";
export class PromptBuilder {
async build(input: unknown): Promise<string> {
const fragments = await loadFragments(input);
const ranked = rankFragments(fragments);
const injected = injectContext(ranked);
return renderOutput(injected, this.outputTemplate);
}
// ...
}
// The singleton is created exactly once, here — not in the barrel.
export const promptBuilder = new PromptBuilder();
// src/core/prompt-builder/index.ts (barrel)
export { PromptBuilder, promptBuilder } from "./builder.js";
export { rankFragments } from "./ranks.js";
export { injectContext } from "./injection.js";
export { loadFragments } from "./fragment-loader.js";
export { renderOutput } from "./output-template.js";
export type { PromptFragment, RankFragment, OutputTemplate, PromptBuilderOptions } from "./types.js";
All 10 importers still import promptBuilder from the same resolved path (prompt-builder/index.js), so ESM guarantees they share the identical instance. The barrel only re-exports; it never calls new PromptBuilder().
2. Shared types in types.ts to prevent circular imports
The risk was a runtime cycle (e.g., ranks.ts ↔ injection.ts, or both ↔ builder.ts). All shared interfaces moved to types.ts, which has no runtime imports, so it cannot participate in a cycle; cross-module dependencies are strictly one-way at runtime (types are erased at compile time):
// types.ts — zero imports; types only
export interface PromptFragment { id: string; rank: number; source: string; }
export interface RankFragment { fragment: PromptFragment; score: number; }
export interface OutputTemplate { header: string; body: string; footer: string; }
export interface PromptBuilderOptions { repoRoot: string; maxTokens: number; }
Anywhere a type was needed: import type { PromptFragment } from "./types.js"; — erased at emit, so no cycle at runtime.
3. REPO_ROOT path depth shift (../../../.. → ../../../../..)
The old file lived at src/core/prompt-builder.ts; the new module lives one directory deeper (src/core/prompt-builder/builder.ts). Every __dirname-relative computation gains one level:
// Before (src/core/prompt-builder.ts)
const REPO_ROOT = path.resolve(__dirname, "../../../..");
// After (src/core/prompt-builder/builder.ts)
const REPO_ROOT = path.resolve(__dirname, "../../../../..");
All other files under prompt-builder/ that derive paths from __dirname received the same +1-level adjustment. path.resolve keeps it correct across / and \ separators.
4. ESM .js import-suffix convention
All intra-package relative imports use the explicit .js suffix required by NodeNext/ESM module resolution:
import type { RankFragment } from "./types.js";
import { rankFragments } from "./ranks.js";
5. Public API preserved exactly
Before: prompt-builder.ts exported PromptBuilder, promptBuilder, plus helper functions/types. After: index.ts re-exports the identical named exports (explicit re-exports used where export * could collide), so the 10 importers compile and behave unchanged — verified by a diff containing no changes outside prompt-builder.ts → prompt-builder/.
**Verification performed:**
1. **`tsc` — 0 errors.** Full-project typecheck (`tsc --noEmit`) passes with the new module layout and `.js` suffixes under `module: NodeNext`.
2. **2072 backend tests pass.** The complete backend test suite (unit + integration) runs green — these cover builder behavior, injection, ranking, fragment loading, and output rendering, all now exercised through the barrel, proving the public API is equivalent.
3. **Guard PASS.** The repo's guards pass, including:
- `import/no-cycle` (ESLint) — no circular imports remain; `types.ts` has no runtime edges.
- The singleton-invariant guard: a test asserting `(await import("../prompt-builder/index.js")).promptBuilder === (await import("../prompt-builder/builder.js")).promptBuilder` passes — the instance is defined once and never re-instantiated.
- Pre-push lint/format guards clean.
**Edge cases tested:**
- **Singleton identity via multiple entry points** — importing `promptBuilder` from the barrel and from the deep `builder.js` path yields the same object reference (no accidental `new PromptBuilder()` in `index.ts`).
- **Circular imports** — `types.ts` is dependency-free at runtime; all other cycles that existed in the monolithic file are structurally impossible now; `import type` usage verified erased in emitted JS.
- **`REPO_ROOT` depth** — tests that read repo files via the constant still resolve the same absolute paths after the +1 depth change; a regression test asserting `fs.existsSync(REPO_ROOT)` passes.
- **ESM `.js` suffixes** — no `ERR_MODULE_NOT_FOUND` at runtime under NodeNext; every relative import ends in `.js`.
- **Barrel `export *` collisions** — explicit named re-exports used where names overlapped; no duplicate-export TS errors.
- **Importers untouched** — `git diff` shows changes confined to `prompt-builder.ts`/`prompt-builder/`; the 10 importing files are byte-identical.
- **Cross-platform paths** — `path.resolve` handles both `/` and `\`, so the depth shift is separator-agnostic.
---{"model": "deepseek-v4-flash", "problem_class": "typescript-refactor-large-file-barrel-split", "result": "passed", "tests": 2072}