--- issue: 32 issue_title: "Drop .js extensions from all internal imports" --- # Drop `.js` extensions from all internal imports ## Problem Statement All internal imports in `src/` and `tests/` carry explicit `.js` extensions (e.g. `from "./common.js"`). This convention is required by `moduleResolution: "Node16"` / `"NodeNext"`, but the project uses `moduleResolution: "Bundler"` with `noEmit: true`, so the extensions serve no purpose. They add noise to every new file and are a common source of copy-paste mistakes. ## Goals - Strip `.js` from every relative import path in `src/` and `tests/` (~116 occurrences across ~30 files). - Add a guard so `.js` extensions do not creep back in. - Verify `npx vitest run` and `npm run build` both pass after the change. ## Non-Goals - No behavior change to the permission system. - No changes to `package.json`, `tsconfig.json`, or the public API. - No changes to imports of `node:*` builtins or bare package specifiers (only relative `./` and `../` paths). ## Background - `tsconfig.json` uses `"module": "ESNext"` and `"moduleResolution": "Bundler"` — both resolve extensionless relative imports natively. - Vitest transforms via esbuild, which also resolves extensionless imports. - `tsc` is `"noEmit": true` — Node's strict-ESM extension rule never fires. - **Runtime loading**: Pi loads extensions via `@mariozechner/jiti` (a fork of `unjs/jiti`), which uses esbuild internally and resolves extensionless `.ts` imports the same way Vitest does. Empirically verified: stripping `.js` from `src/permission-manager.ts` imports passes both `npm run build` and `npx vitest run` with no changes. - Biome has `useImportExtensions` (enforces extensions), which is the opposite of what we want. There is no built-in Biome rule to *ban* extensions, so prevention uses a lint script. No permission surface is involved — this is a purely mechanical code-quality change. ## Design Overview ### Step 1: Mechanical find-and-replace Use `sed` or a script to strip `.js` from every relative import in `src/**/*.ts` and `tests/**/*.ts`: ```text from "./foo.js" → from "./foo" from "../bar.js" → from "../bar" ``` Only touch relative paths (starting with `./` or `../`). Do not touch `node:*`, bare specifiers, or type-only imports (they follow the same rule, so strip those too). ### Step 2: Prevention guard Add a `lint:imports` npm script that greps for `.js"` in relative imports and fails if any are found: ```jsonc "lint:imports": "! grep -rn --include='*.ts' 'from \"\\.\\{1,2\\}/.*\\.js\"' src/ tests/" ``` Wire it into `lint:all` so `npm run check` catches regressions. ### Step 3: Verify Run `npm run build` (tsc) and `npm run test` (vitest). Both must pass with zero changes beyond the import paths. ## Module-Level Changes | File / glob | Change | | --------------------------- | ----------------------------------------------- | | `src/**/*.ts` (~20 files) | Strip `.js` from relative import paths | | `tests/**/*.ts` (~10 files) | Strip `.js` from relative import paths | | `package.json` | Add `lint:imports` script; append to `lint:all` | No schema, config, or documentation changes required. ## TDD Order This change is mechanical with no new logic, so a classic red→green cycle is lightweight: 1. **Green: strip extensions in `src/`** — run `npm run build` to confirm tsc still passes. Commit: `refactor: drop .js extensions from src/ imports (#32)` 2. **Green: strip extensions in `tests/`** — run `npx vitest run` to confirm tests still pass. Commit: `refactor: drop .js extensions from tests/ imports (#32)` 3. **Guard: add lint:imports script** — add the grep guard and verify it passes on the cleaned tree and fails if a `.js` extension is reintroduced. Commit: `chore: add lint:imports guard against .js extensions (#32)` Alternatively, steps 1–2 can be a single commit since the change is atomic and trivially reversible. ## Risks and Mitigations | Risk | Mitigation | | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | Missed an import that actually needs `.js` (e.g. a non-TS asset) | Only strip from `*.ts` files with relative paths; `npm run build` and `npm run test` catch any resolution failures immediately. | | Could this silently weaken a permission? | No — this change touches only import specifier strings, not runtime behavior or policy resolution. | | `lint:imports` grep is too broad / too narrow | The pattern targets `from "..js"` only; tested against the cleaned tree and a synthetic reintroduction before committing. | ## Open Questions None — the issue's proposed change is unambiguous and the project's toolchain already supports extensionless imports.