Skip to content

refactor(web-shared): add the @kilocode/web-shared import specifier - #7056

Closed
chrarnoldus wants to merge 3 commits into
mainfrom
web-shared-specifier
Closed

chrarnoldus wants to merge 3 commits into
mainfrom
web-shared-specifier

Conversation

@chrarnoldus

@chrarnoldus chrarnoldus commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

First preparation PR for #7051, which removes the @/ fallback between apps/web and packages/web-shared. The stack is this PR, then #7058 (web-shared stops importing apps/web), then #7051. This PR introduces the explicit specifier and adopts it inside web-shared. It does not change how any existing @/ import resolves.

  • Specifier. @kilocode/web-shared/<path> maps to packages/web-shared/src/<path>, wired like @kilocode/db/*. The mapping is added in:

    • tsconfig paths in apps/web (tsconfig.json, tsconfig.scripts.json, inherited by Storybook), apps/ai-gateway, packages/trpc and packages/web-shared (tsconfig.json and tsconfig.lib.json)
    • the web Jest moduleNameMapper, the @kilocode/trpc rollup resolver and the Storybook webpack alias
    • the MCP catalog import resolver (apps/web/src/scripts/mcp-catalog/catalog.ts)
    • a workspace:* devDependency in packages/trpc, as for @kilocode/db and @kilocode/encryption

    There is no exports map: the specifiers are extensionless and point at .ts/.tsx files and index.ts directories, which an exports wildcard cannot probe.

  • Adoption inside web-shared. Every @/ specifier in packages/web-shared that resolved into the package itself now uses the package specifier. This exercises the mapping in the apps/web and apps/ai-gateway Next.js builds, Jest, Storybook and the trpc build before refactor(web-shared): import web-shared via @kilocode/web-shared #7051 depends on it. Specifiers that resolve into apps/web (test helpers) are unchanged.

  • Docs. packages/web-shared/AGENTS.md documents the specifier. The fallback description stays until refactor(web-shared): import web-shared via @kilocode/web-shared #7051 removes it.

Codemod

The rewrite is the TypeScript compiler API codemod from #7051 (source below), run with --only packages/web-shared. It collects string literals only in module-specifier positions:

  • static import/export … from, import x = require()
  • dynamic import(), require(), require.resolve()
  • every jest.*('…') call: mock, doMock, dontMock, requireActual, requireMock, …
  • typeof import('…') types and declare module '…'

It resolves each @/ specifier with ts.resolveModuleName against both the apps/web tsconfig (the order Next.js and Jest use) and web-shared's own tsconfig. If the target is in packages/web-shared/src, it swaps the prefix. The two resolutions never disagreed, and no specifier was unresolved.

323 files, 1,465 specifiers on 1,465 lines. After oxfmt, the commit is 323 files changed, 1725 insertions(+), 1475 deletions(-). The extra lines are imports that the longer specifier pushed past the line limit and the formatter rewrapped. The files were oxfmt-clean before the rewrite.

Verification

  • cd apps/web && pnpm exec tsgo --noEmit, apps/ai-gateway typecheck, @kilocode/web-shared typecheck (tsconfig.lib.json) and tsgo -p packages/web-shared/tsconfig.json: all pass
  • apps/storybook typecheck and pnpm build-storybook: both succeed
  • pnpm --filter @kilocode/trpc build: succeeds, and the bundles contain no unresolved @kilocode/web-shared or @/ references. apps/mobile typecheck passes against it
  • next build of apps/ai-gateway with apps/web/.env.test as .env.production.local and apps/web/.env as .env, deleted afterwards: succeeds, all 15 /api/v1 routes built
  • cd apps/web && pnpm script src/scripts/mcp-catalog/dump.ts: services/kilo-mcp/catalog.json is unchanged
  • cd apps/web && NODE_ENV=test pnpm exec jest --listTests: lists all 168 web-shared tests. Smoke-ran 4 web-shared suites that use jest.requireActual, jest.requireMock and jest.isolateModules with the new specifier, including llm-proxy.test.ts: 87 tests pass. CI runs the full suites
  • oxlint on packages/web-shared/src, packages/trpc and the mcp-catalog dir, oxfmt --list-different on changed files and git diff --check: all clean

Visual Changes

N/A

Reviewer Notes

  • Commits:
    1. Mapping and tooling (additive).
    2. Mechanical codemod rewrite plus oxfmt only.
    3. Docs.
  • To check commit 2, run git show <sha> -U0 | rg '^[+-][^+-]' | rg -v '@kilocode/web-shared/|@/'. It shows only rewrapped import and jest.* lines.
  • On a rebase conflict in commit 2, re-run the codemod rather than resolving by hand.
Codemod source (rewrite-web-shared-imports.mjs, run as node rewrite-web-shared-imports.mjs . --only packages/web-shared && git diff --name-only -z | xargs -0 pnpm exec oxfmt)
// Rewrites `@/...` module specifiers that resolve into packages/web-shared/src
// to `@kilocode/web-shared/...`. Only string literals in module-specifier
// positions change; everything else in the file is left byte-for-byte intact.
//
// Usage: node rewrite-web-shared-imports.mjs <repo-root> [--dry-run] [--only <dir>]
//
// Each `@/` specifier is resolved with ts.resolveModuleName against the
// tsconfig that owns the importing file, as it was configured before the
// fallback was removed (own src first, then packages/web-shared/src). Files in
// packages/web-shared are additionally resolved with the apps/web order, since
// apps/web Next.js and Jest resolve them that way; any disagreement aborts.

import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';

const repoRoot = path.resolve(process.argv[2] ?? '.');
const dryRun = process.argv.includes('--dry-run');
const onlyIndex = process.argv.indexOf('--only');
const onlyDir = onlyIndex === -1 ? undefined : process.argv[onlyIndex + 1];
const require = createRequire(path.join(repoRoot, 'package.json'));
const ts = require('typescript');

const WEB_SHARED_SRC = path.join(repoRoot, 'packages/web-shared/src') + path.sep;
const NEW_PREFIX = '@kilocode/web-shared/';

// Directory → tsconfig(s) used to resolve `@/` in files under it. The first
// entry decides; later entries must agree.
const PROJECTS = [
  { dir: 'apps/web', tsconfigs: ['apps/web/tsconfig.json'] },
  { dir: 'apps/ai-gateway', tsconfigs: ['apps/ai-gateway/tsconfig.json'] },
  { dir: 'apps/storybook', tsconfigs: ['apps/storybook/tsconfig.json'] },
  { dir: 'packages/trpc', tsconfigs: ['packages/trpc/tsconfig.json'] },
  {
    dir: 'packages/web-shared',
    tsconfigs: ['apps/web/tsconfig.json', 'packages/web-shared/tsconfig.json'],
  },
  // Runs under the apps/web Jest config (see services/deploy-infra/builder/package.json).
  {
    dir: 'services/deploy-infra/builder/test-postgres',
    tsconfigs: ['apps/web/tsconfig.json'],
  },
];

const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
const PROBE_SUFFIXES = [
  '',
  '.ts',
  '.tsx',
  '.d.ts',
  '.js',
  '.jsx',
  '.json',
  '/index.ts',
  '/index.tsx',
  '/index.js',
];

const parsedConfigs = new Map();
function compilerOptionsFor(tsconfigRel) {
  let options = parsedConfigs.get(tsconfigRel);
  if (!options) {
    const parsed = ts.getParsedCommandLineOfConfigFile(path.join(repoRoot, tsconfigRel), {}, {
      ...ts.sys,
      onUnRecoverableConfigFileDiagnostic: diagnostic => {
        throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'));
      },
    });
    if (!parsed) throw new Error(`Cannot parse ${tsconfigRel}`);
    options = parsed.options;
    parsedConfigs.set(tsconfigRel, options);
  }
  return options;
}

// Non-TS assets (and specifiers TS cannot resolve) are probed against the
// `@/*` targets of the tsconfig, in order.
function probeWithPaths(specifier, options) {
  const targets = options.paths?.['@/*'] ?? [];
  const base = options.pathsBasePath ?? options.baseUrl;
  for (const target of targets) {
    const candidate = path.resolve(base, target.replace('*', specifier.slice(2)));
    for (const suffix of PROBE_SUFFIXES) {
      const file = candidate + suffix;
      if (existsSync(file) && statSync(file).isFile()) return file;
    }
  }
  return undefined;
}

function resolveSpecifier(specifier, containingFile, tsconfigRel) {
  const options = compilerOptionsFor(tsconfigRel);
  const result = ts.resolveModuleName(specifier, containingFile, options, ts.sys);
  return result.resolvedModule?.resolvedFileName ?? probeWithPaths(specifier, options);
}

function listFiles(dir) {
  const output = execFileSync('git', ['ls-files', '-z', '--', dir], {
    cwd: repoRoot,
    encoding: 'utf8',
    maxBuffer: 64 * 1024 * 1024,
  });
  return output
    .split('\0')
    .filter(Boolean)
    .filter(file => SOURCE_EXTENSIONS.has(path.extname(file)) && !file.endsWith('.d.ts.map'));
}

function scriptKindFor(file) {
  switch (path.extname(file)) {
    case '.tsx':
      return ts.ScriptKind.TSX;
    case '.jsx':
      return ts.ScriptKind.JSX;
    case '.js':
    case '.mjs':
    case '.cjs':
      return ts.ScriptKind.JS;
    default:
      return ts.ScriptKind.TS;
  }
}

function isStringLike(node) {
  return node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node));
}

// String literals in module-specifier positions.
function collectSpecifierNodes(sourceFile) {
  const found = [];
  const visit = node => {
    if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
      if (isStringLike(node.moduleSpecifier)) found.push(node.moduleSpecifier);
    } else if (
      ts.isImportEqualsDeclaration(node) &&
      ts.isExternalModuleReference(node.moduleReference) &&
      isStringLike(node.moduleReference.expression)
    ) {
      found.push(node.moduleReference.expression);
    } else if (ts.isImportTypeNode(node)) {
      const literal = ts.isLiteralTypeNode(node.argument) ? node.argument.literal : undefined;
      if (isStringLike(literal)) found.push(literal);
    } else if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
      found.push(node.name);
    } else if (ts.isCallExpression(node) && isStringLike(node.arguments[0])) {
      const callee = node.expression;
      const isDynamicImport = callee.kind === ts.SyntaxKind.ImportKeyword;
      const isRequire = ts.isIdentifier(callee) && callee.text === 'require';
      const isRequireResolve =
        ts.isPropertyAccessExpression(callee) &&
        ts.isIdentifier(callee.expression) &&
        callee.expression.text === 'require' &&
        callee.name.text === 'resolve';
      // jest.mock, jest.doMock, jest.unmock, jest.dontMock, jest.requireActual,
      // jest.requireMock, jest.createMockFromModule, jest.setMock, ...
      const isJestModuleCall =
        ts.isPropertyAccessExpression(callee) &&
        ts.isIdentifier(callee.expression) &&
        callee.expression.text === 'jest';
      if (isDynamicImport || isRequire || isRequireResolve || isJestModuleCall) {
        found.push(node.arguments[0]);
      }
    }
    ts.forEachChild(node, visit);
  };
  visit(sourceFile);
  return found;
}

// Every `@/` string literal, to report the ones not in a specifier position.
function collectAllAliasLiterals(sourceFile) {
  const found = [];
  const visit = node => {
    if (isStringLike(node) && node.text.startsWith('@/')) found.push(node);
    ts.forEachChild(node, visit);
  };
  visit(sourceFile);
  return found;
}

const stats = { files: 0, specifiers: 0, lines: 0 };
const unresolved = [];
const disagreements = [];
const ignoredLiterals = [];
const perProject = {};

for (const project of PROJECTS.filter(p => !onlyDir || p.dir === onlyDir)) {
  const counts = { files: 0, specifiers: 0 };
  for (const relFile of listFiles(project.dir)) {
    const absFile = path.join(repoRoot, relFile);
    const text = readFileSync(absFile, 'utf8');
    if (!text.includes('@/')) continue;
    const sourceFile = ts.createSourceFile(
      absFile,
      text,
      ts.ScriptTarget.Latest,
      true,
      scriptKindFor(relFile)
    );
    const specifierNodes = collectSpecifierNodes(sourceFile);
    const specifierSet = new Set(specifierNodes);
    const edits = [];
    for (const node of specifierNodes) {
      const specifier = node.text;
      if (!specifier.startsWith('@/')) continue;
      const [primary, ...checks] = project.tsconfigs;
      const resolved = resolveSpecifier(specifier, absFile, primary);
      const location = `${relFile}:${sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1}`;
      if (!resolved) {
        unresolved.push(`${location} ${specifier}`);
        continue;
      }
      for (const check of checks) {
        const other = resolveSpecifier(specifier, absFile, check);
        if (other !== resolved) {
          disagreements.push(`${location} ${specifier}: ${primary} → ${resolved}, ${check} → ${other}`);
        }
      }
      if (!resolved.startsWith(WEB_SHARED_SRC)) continue;
      const start = node.getStart(sourceFile) + 1; // skip the opening quote
      edits.push({ start, end: start + 2, line: location });
    }
    for (const literal of collectAllAliasLiterals(sourceFile)) {
      if (!specifierSet.has(literal)) {
        const line = sourceFile.getLineAndCharacterOfPosition(literal.getStart()).line + 1;
        ignoredLiterals.push(`${relFile}:${line} ${literal.text}`);
      }
    }
    if (edits.length === 0) continue;
    let next = text;
    for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
      if (next.slice(edit.start, edit.end) !== '@/') throw new Error(`Unexpected text at ${edit.line}`);
      next = next.slice(0, edit.start) + NEW_PREFIX + next.slice(edit.end);
    }
    const changedLines = new Set(edits.map(edit => edit.line)).size;
    stats.files += 1;
    stats.specifiers += edits.length;
    stats.lines += changedLines;
    counts.files += 1;
    counts.specifiers += edits.length;
    if (!dryRun) writeFileSync(absFile, next);
  }
  perProject[project.dir] = counts;
}

console.log(JSON.stringify({ total: stats, perProject }, null, 2));
if (ignoredLiterals.length > 0) {
  console.log(`\n@/ string literals outside specifier positions (left unchanged):`);
  for (const entry of ignoredLiterals) console.log(`  ${entry}`);
}
if (unresolved.length > 0) {
  console.log(`\nUnresolved @/ specifiers (left unchanged):`);
  for (const entry of unresolved) console.log(`  ${entry}`);
}
if (disagreements.length > 0) {
  console.error(`\nResolution disagreements:`);
  for (const entry of disagreements) console.error(`  ${entry}`);
  process.exitCode = 1;
}

Add the explicit specifier next to the existing @/ fallback in every
consumer tsconfig, the web Jest config, the trpc rollup resolver, the
Storybook webpack alias and the MCP catalog import resolver, and declare
the workspace devDependency in packages/trpc like @kilocode/db.
…web-shared

Mechanical codemod output only, followed by oxfmt. Every `@/` module
specifier in packages/web-shared that resolved into packages/web-shared/src
now uses `@kilocode/web-shared/`. Specifiers that resolve into apps/web
(test helpers) are unchanged.

323 files, 1,465 specifiers on 1,465 lines.
@chrarnoldus chrarnoldus self-assigned this Oct 1, 2026
@kilo-code-bot

kilo-code-bot Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Reviewed the additive @kilocode/web-shared/* specifier wiring and the mechanical @/ → @kilocode/web-shared/ rewrite, including config coverage, resolver parity across Next/Jest/Storybook/rollup/tsc, and the MCP catalog resolver.

All 230 distinct rewritten specifiers resolve to existing files under packages/web-shared/src; the whitespace-insensitive diff shows only specifier replacements plus formatter line rewrapping.

Files Reviewed (336 files)

Non-mechanical changes:

  • apps/web/tsconfig.json, apps/web/tsconfig.scripts.json - added @kilocode/web-shared/* path
  • apps/ai-gateway/tsconfig.json - added path
  • packages/trpc/tsconfig.json, packages/trpc/package.json, packages/trpc/rollup.config.mjs - path, workspace dep, rollup resolver branch
  • packages/web-shared/tsconfig.json, packages/web-shared/tsconfig.lib.json - added path
  • apps/web/jest.config.ts - added moduleNameMapper entry (ordered before @/)
  • apps/storybook/.storybook/main.ts - added webpack alias
  • apps/web/src/scripts/mcp-catalog/catalog.ts - resolver branch (REPO_ROOT / WEB_SHARED_SRC_DIR correct)
  • packages/web-shared/AGENTS.md - documentation
  • pnpm-lock.yaml - lockfile link for the new workspace devDependency

Mechanical (import-specifier rewrite only, verified no semantic body changes):

  • 323 files under packages/web-shared/src/**

Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0

Review guidance: REVIEW.md from base branch main

@chrarnoldus chrarnoldus closed this Oct 2, 2026
@chrarnoldus
chrarnoldus deleted the web-shared-specifier branch October 2, 2026 08:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant