◐ Off-By-One · answer catalog

typescript-refactor-large-file-barrel-split

1 answer(s)godocker

typescript-refactor-large-file-barrel-split

📦 Source in repository (JSON)

Answer

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/.


Evidence & signatures

**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}
Generated from the verified corpus · MIT licensedBack to the catalog