Skip to content

refactor(web-shared): import web-shared via @kilocode/web-shared - #7051

Closed
chrarnoldus wants to merge 12 commits into
mainfrom
web-shared-package-imports
Closed

chrarnoldus wants to merge 12 commits into
mainfrom
web-shared-package-imports

Conversation

@chrarnoldus

@chrarnoldus chrarnoldus commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

#6806 moved ~450 files from apps/web/src to packages/web-shared/src without changing their @/ specifiers, so every consumer resolved @/* to its own src/* first and to packages/web-shared/src/* second. A path could exist in only one tree, and a duplicate silently shadowed the web-shared file inside apps/web. packages/web-shared/tsconfig.json also fell back to apps/web/src, so the package depended on the app. This PR removes both fallbacks.

  • Specifier. @kilocode/web-shared/<path> maps to packages/web-shared/src/<path>. It is wired the same way as @kilocode/db/*: tsconfig paths in apps/web (tsconfig.json, tsconfig.scripts.json, inherited by Storybook), apps/ai-gateway, packages/trpc and packages/web-shared itself, plus the web Jest moduleNameMapper, the @kilocode/trpc rollup resolver and the Storybook webpack alias. Code inside web-shared uses the same specifier, not relative paths. That keeps one spelling per module everywhere, so moving a file between the two trees only changes its prefix.
  • No exports map in packages/web-shared/package.json. The specifiers are extensionless and point at .ts/.tsx files and index.ts directories. An exports wildcard cannot express extension or index probing, so resolution relies on the mappings above, as @kilocode/db/* subpaths do in practice. packages/trpc now declares @kilocode/web-shared as a workspace:* devDependency, as it already does for @kilocode/db and @kilocode/encryption.
  • @/ fallback removed. @/* in apps/web, apps/ai-gateway, packages/trpc, the web Jest config and the Storybook alias maps only to the consumer's own src. Shadowing is no longer possible: a @/ path that only exists in web-shared fails to resolve.
  • web-shared no longer imports apps/web, tests included.
    • packages/web-shared/tsconfig.json has no @/ mapping and no reference to apps/web.
    • pnpm --filter @kilocode/web-shared typecheck now runs tsconfig.lib.json (runtime code only, no Jest types) and tsconfig.json (everything, tests included).
    • An oxlint no-restricted-imports rule rejects @/ and relative apps/web imports anywhere in the package.
    • With throwaway files I checked that a runtime @/ import fails the lib config (TS2307), a test @/ import fails tsconfig.json (TS2882), and both @/ and ../../../../apps/web/... imports fail oxlint.
  • Test helpers moved into web-shared. Seven apps/web helpers used by web-shared tests (user, organization, microdollar-usage, approval, payment-method, openrouter-models, fake-r2) moved to packages/web-shared/src/tests/helpers. Four self-contained modules that those helpers or web-shared tests need also moved to the same paths in web-shared: lib/auth/constants, lib/organizations/constants, lib/ai-gateway/latest-model-aliases and lib/balanceCache. They are constants, plus a 14-line helper that only depends on web-shared. All moves used git mv. A script (below) rewrote their importers to @kilocode/web-shared/...: 405 specifiers in 326 files for the helpers and constants, and 8 specifiers in 8 files for balanceCache. src/tests is excluded from tsconfig.lib.json and from Storybook.
  • Tests that exercise apps/web code moved to apps/web. No test case was dropped; each file's case count adds up.
    • kilo-pass-org/consumption.test.ts (fixtures use the apps/web Kilo Pass org service) and model-stats/model-stats-cache.test.ts (drives the apps/web /api/models/stats routes) moved whole, with git mv, to the same paths in apps/web.
    • From organizations.test.ts and organization-usage.test.ts: the removeUserFromOrganization cases and the cases that use it moved to apps/web/src/lib/organizations/organization-member-removal.test.ts and organization-member-removal-usage.test.ts (100 = 85 + 15, 60 = 58 + 2).
    • From user/server.test.ts: the next-auth-options, passkey and platform-admin-grant cases moved to apps/web/src/lib/user/next-auth-options.test.ts, and the getProfileRedirectPath cases to profile-redirect-path.test.ts (125 = 77 + 42 + 6).
    • From kilo-pass/issuance.test.ts: the subscription-accounting cases moved to apps/web/src/lib/kilo-pass/subscription-accounting.test.ts (14 = 12 + 2). Their shared createTestSubscription fixture became tests/helpers/kilo-pass.helper.ts.
    • The upstream-budget assertion in trpc/control-plane-budget.test.ts moved into its own case in apps/web/src/lib/bounded-service-fetch.test.ts.
    • web-shared tests still run under the apps/web Jest config, which owns the DB globalSetup.
  • Tooling.
    • The trpc rollup resolver maps @/ to apps/web/src and @kilocode/web-shared/ to packages/web-shared/src. The rebuilt dist/index.d.ts and dist/mobile.d.ts are byte-identical to the build from main.
    • The MCP catalog import resolver (apps/web/src/scripts/mcp-catalog/catalog.ts) now follows @kilocode/web-shared/ into web-shared. Before, it did not follow the fallback at all. No sub-router lives in web-shared today, so the regenerated services/kilo-mcp/catalog.json is unchanged.
    • The source-root guardrails (email-literal-guardrail.test.ts, spend-writer-audit.test.ts, kiloclaw-price-rollout-audit.ts) scan file paths, not specifiers, so they needed no change.
  • Docs. packages/web-shared/AGENTS.md and apps/ai-gateway/AGENTS.md describe the specifier and the no-apps/web rule instead of the fallback and shadowing rule. Code comments that named web-shared modules by @/ path now use the new specifier.

Codemod

The specifier rewrite is a TypeScript compiler API codemod (source below). It parses every git-tracked .ts/.tsx/.js/.jsx/.mjs/.cjs file under apps/web, apps/ai-gateway, apps/storybook, packages/trpc, packages/web-shared and services/deploy-infra/builder/test-postgres. The last one runs under the apps/web Jest config. The codemod 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 the pre-change tsconfig that owns the file (own src first, then web-shared). If the target is in packages/web-shared/src, it replaces the @/ prefix with @kilocode/web-shared/. Files in web-shared were resolved with both the apps/web order (what Next.js and Jest use) and web-shared's own tsconfig order. The run aborts if the two disagree, and they never did. No specifier was unresolved. The only @/ string literals outside specifier positions are config and tooling code (the Storybook @/lib/utils mock alias and resolver prefixes), which the codemod left alone. After rebasing on main, I re-ran the codemod on top of the first commit rather than resolving import conflicts by hand.

Scope Files Specifiers
apps/web 1,645 4,041
packages/web-shared 323 1,465
apps/ai-gateway 15 21
apps/storybook 3 5
packages/trpc 2 2
services/deploy-infra/builder/test-postgres 1 1
Total 1,989 5,535

The 5,535 specifiers sit on 5,533 lines: two lines carry two specifiers each. After oxfmt, the commit is 1989 files changed, 6405 insertions(+), 5561 deletions(-). The extra lines are import statements that the longer specifier pushed past the line limit and the formatter rewrapped. The files were oxfmt-clean before the rewrite, so no unrelated reformatting is mixed in. That is more than the ~2,000 lines estimated in the issue, because @/lib/drizzle, @/lib/user/server, @/lib/trpc/init, @/lib/config.server and @/lib/constants alone account for ~2,000 lines.

Verification

  • cd apps/web && pnpm exec tsgo --noEmit, apps/ai-gateway and apps/storybook tsgo --noEmit, and pnpm --filter @kilocode/web-shared typecheck (both configs): all pass on the final branch. apps/web also passed right after the codemod, with the fallback still in place
  • pnpm --filter @kilocode/trpc build: dist/index.d.ts and dist/mobile.d.ts are byte-identical to the main build. apps/mobile typecheck passes against it
  • cd apps/web && pnpm script src/scripts/mcp-catalog/dump.ts: writes 864 rows, and services/kilo-mcp/catalog.json is unchanged
  • cd apps/web && NODE_ENV=test pnpm exec jest --listTests: lists 1,122 tests, including all 166 remaining packages/web-shared/src/**/*.test.ts
  • Ran every split or moved suite against the local test DB, plus the suites that use moved helpers and constants: 13 split suites (376 passed, 2 skipped, as on main) and 7 helper-dependent suites, all passing. CI runs the full suites
  • pnpm build-storybook succeeded with the new alias. I ran it before the test-helper moves, which touch Storybook only through a tsconfig exclude
  • next build of apps/ai-gateway with apps/web/.env.test as .env.production.local and apps/web/.env as .env, deleted afterwards: succeeds
  • pnpm --filter cloud-agent-next exec vitest run src/telemetry/report-consumer.glanceable.test.ts: 14 passed
  • oxlint on every changed directory, oxfmt --list-different on changed files and git diff --check: all clean

Visual Changes

N/A

Reviewer Notes

  • Each commit typechecks on its own:
    1. Adds the @kilocode/web-shared/* mapping next to the existing fallback.
    2. The mechanical codemod rewrite plus oxfmt only. Review it by checking that every changed line is a specifier swap or a formatter rewrap: git show <sha> -U0 | rg '^[+-][^+-]' | rg -v '@kilocode/web-shared/|@/' shows only rewrapped import/jest.mock lines.
    3. Updates three vi.mock('@/lib/…') specifiers in services/cloud-agent-next/src/telemetry/report-consumer.glanceable.test.ts. That Vitest test loads apps/web's active-sessions-list and mocks its imports by specifier, outside the codemod's scope.
    4. Removes the @/ fallback and updates the trpc and MCP catalog resolvers.
    5. Docs and comments.
    6. Declares the trpc devDependency (Kilobot remark).
    7. Excludes src/tests from the runtime typecheck.
    8. Moves the helpers and constants into web-shared (script output plus oxfmt).
    9. Moves balanceCache into web-shared (script output plus oxfmt).
    10. Moves test cases into apps/web. This is the only hand-made test change; it is pure cut and paste plus imports.
    11. Drops @/ from web-shared's tsconfig, typechecks tests, and adds the lint rule.
    12. Docs.
  • On a rebase conflict in commit 2, 8 or 9, drop the commit and re-run its script rather than resolving import conflicts by hand.
Codemod source (rewrite-web-shared-imports.mjs, run as node rewrite-web-shared-imports.mjs . && 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]
//
// 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 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) {
  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;
}
Move script (move-to-web-shared.mjs, run as node move-to-web-shared.mjs . tests/helpers/user.helper.ts … lib/ai-gateway/latest-model-aliases.ts, then node move-to-web-shared.mjs . lib/balanceCache.ts)
// Moves apps/web modules into packages/web-shared (same path under src/) and
// rewrites every module specifier that resolved to a moved file to
// `@kilocode/web-shared/<path>`. Only string literals in module-specifier
// positions change.
//
// Usage: node move-to-web-shared.mjs <repo-root> <apps/web/src-relative path>...
//   e.g. node move-to-web-shared.mjs . tests/helpers/user.helper.ts lib/auth/constants.ts

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

const repoRoot = path.resolve(process.argv[2]);
const moved = process.argv.slice(3);
const require = createRequire(path.join(repoRoot, 'package.json'));
const ts = require('typescript');

const WEB_SRC = path.join(repoRoot, 'apps/web/src');
const SHARED_SRC = path.join(repoRoot, 'packages/web-shared/src');
const movedAbs = new Map(
  moved.map(rel => [path.join(WEB_SRC, rel), rel.replace(/(\/index)?\.(tsx?|jsx?)$/, '')])
);
for (const [abs, rel] of movedAbs) {
  if (!existsSync(abs)) throw new Error(`Missing ${abs}`);
  if (existsSync(path.join(SHARED_SRC, path.relative(WEB_SRC, abs)))) {
    throw new Error(`Target exists for ${rel}`);
  }
}

const PROJECTS = [
  { dir: 'apps/web', tsconfig: 'apps/web/tsconfig.json' },
  { dir: 'apps/ai-gateway', tsconfig: 'apps/ai-gateway/tsconfig.json' },
  { dir: 'apps/storybook', tsconfig: 'apps/storybook/tsconfig.json' },
  { dir: 'packages/trpc', tsconfig: 'packages/trpc/tsconfig.json' },
  { dir: 'packages/web-shared', tsconfig: 'packages/web-shared/tsconfig.json' },
  { dir: 'services/deploy-infra/builder/test-postgres', tsconfig: 'apps/web/tsconfig.json' },
  { dir: 'dev', tsconfig: 'apps/web/tsconfig.json' },
];
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);

const optionsCache = new Map();
function optionsFor(tsconfig) {
  if (!optionsCache.has(tsconfig)) {
    const parsed = ts.getParsedCommandLineOfConfigFile(path.join(repoRoot, tsconfig), {}, {
      ...ts.sys,
      onUnRecoverableConfigFileDiagnostic: d => {
        throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
      },
    });
    optionsCache.set(tsconfig, parsed.options);
  }
  return optionsCache.get(tsconfig);
}

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

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';
      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;
}

function scriptKindFor(file) {
  const ext = path.extname(file);
  if (ext === '.tsx') return ts.ScriptKind.TSX;
  if (ext === '.jsx') return ts.ScriptKind.JSX;
  if (ext === '.js' || ext === '.mjs' || ext === '.cjs') return ts.ScriptKind.JS;
  return ts.ScriptKind.TS;
}

const stats = { files: 0, specifiers: 0 };
const problems = [];
const pendingWrites = [];
for (const project of PROJECTS) {
  const files = execFileSync('git', ['ls-files', '-z', '--', project.dir], {
    cwd: repoRoot,
    encoding: 'utf8',
    maxBuffer: 64 * 1024 * 1024,
  })
    .split('\0')
    .filter(file => file && SOURCE_EXTENSIONS.has(path.extname(file)));
  const options = optionsFor(project.tsconfig);
  for (const relFile of files) {
    const absFile = path.join(repoRoot, relFile);
    const text = readFileSync(absFile, 'utf8');
    const sourceFile = ts.createSourceFile(absFile, text, ts.ScriptTarget.Latest, true, scriptKindFor(relFile));
    const edits = [];
    for (const node of collectSpecifierNodes(sourceFile)) {
      const specifier = node.text;
      if (!specifier.startsWith('.') && !specifier.startsWith('@/')) continue;
      const resolved = ts.resolveModuleName(specifier, absFile, options, ts.sys).resolvedModule
        ?.resolvedFileName;
      if (!resolved || !movedAbs.has(resolved)) continue;
      const start = node.getStart(sourceFile) + 1;
      edits.push({ start, end: node.end - 1, text: `@kilocode/web-shared/${movedAbs.get(resolved)}` });
    }
    if (movedAbs.has(absFile)) {
      for (const node of collectSpecifierNodes(sourceFile)) {
        if (node.text.startsWith('.')) problems.push(`${relFile}: relative import ${node.text}`);
      }
    }
    if (edits.length === 0) continue;
    let next = text;
    for (const edit of edits.sort((a, b) => b.start - a.start)) {
      next = next.slice(0, edit.start) + edit.text + next.slice(edit.end);
    }
    pendingWrites.push([absFile, next]);
    stats.files += 1;
    stats.specifiers += edits.length;
  }
}

if (problems.length > 0) {
  console.error(problems.join('\n'));
  process.exit(1);
}
for (const [file, content] of pendingWrites) writeFileSync(file, content);

for (const abs of movedAbs.keys()) {
  const target = path.join(SHARED_SRC, path.relative(WEB_SRC, abs));
  mkdirSync(path.dirname(target), { recursive: true });
  execFileSync('git', ['mv', abs, target], { cwd: repoRoot });
}
console.log(JSON.stringify({ moved: moved.length, ...stats }));

@chrarnoldus chrarnoldus self-assigned this Oct 1, 2026
Comment thread packages/trpc/src/mobile.ts Resolved
@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

Executive Summary

The incremental changes since the prior review move shared test helpers/constants and balanceCache into packages/web-shared/src, relocate the apps/web-dependent test cases into apps/web, drop the @/ mapping from web-shared's tsconfig, and add a no-restricted-imports lint rule; the 357-file delta contains only specifier rewrites, git mvs, and consistent test cut-and-paste, with no new defects.

Verification performed

  • Re-read the active inline comment on packages/trpc/src/mobile.ts:2; it is the author-confirmed fix from the prior review (devDependency declared), so no duplicate was posted.
  • Verified every @kilocode/web-shared/... specifier target exists and that no @/ import remains under packages/web-shared/src.
  • Confirmed the moved apps/web tests use relative imports that resolve next to the test file, and that the new apps/web/jest.config.ts / tsconfig mappings resolve @kilocode/web-shared/* into packages/web-shared/src/*.
  • Excluded apps/mobile/** and services/security-sync/wrangler.jsonc, which differ only from the rebased main, not this PR.
Files Reviewed (357 files)
  • .oxlintrc.json - new web-shared no-restricted-imports rule; no conflict with later web-shared override block
  • packages/web-shared/package.json, tsconfig.json, tsconfig.lib.json - typecheck both configs, @/ mapping removed, src/tests excluded from lib config
  • packages/web-shared/AGENTS.md, apps/storybook/tsconfig.json - docs and test-dir exclude
  • Moved helpers/constants and balanceCache (R100/R096 git mv)
  • New apps/web tests: next-auth-options, profile-redirect-path, subscription-accounting, organization-member-removal, organization-member-removal-usage
  • services/cloud-agent-next/src/telemetry/report-consumer.glanceable.test.ts - mocks updated to moved specifiers
  • Mechanical specifier swaps across apps/web, apps/ai-gateway, apps/storybook, packages/trpc, services/deploy-infra/builder/test-postgres
Previous Review Summaries (2 snapshots, latest commit 3f5682f)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 3f5682f)

Status: No Issues Found | Recommendation: Merge

Executive Summary

The incremental change since the prior review declares the @kilocode/web-shared devDependency in packages/trpc, resolving the only prior finding; the mechanical @/ to @kilocode/web-shared/ refactor and its resolver edits remain consistent.

Files Reviewed (2012 files)
  • packages/trpc/package.json - added @kilocode/web-shared devDependency (resolves prior finding)
  • pnpm-lock.yaml - updated packages/trpc importer
  • packages/trpc/src/mobile.ts, packages/trpc/src/agent-profiles-mobile.ts - specifier swap; prior finding re-verified as fixed
  • apps/web/tsconfig.json, apps/web/tsconfig.scripts.json, apps/web/jest.config.ts - alias maps updated
  • packages/web-shared/tsconfig.json, packages/web-shared/tsconfig.lib.json, apps/ai-gateway/tsconfig.json, packages/trpc/tsconfig.json
  • packages/trpc/rollup.config.mjs, apps/storybook/.storybook/main.ts
  • apps/ai-gateway/AGENTS.md, packages/web-shared/AGENTS.md
  • apps/ai-gateway/src/app/api/v1/** (16 route files), apps/ai-gateway/src/proxy.ts
  • apps/storybook/src/** (3 files)
  • services/deploy-infra/builder/test-postgres/src/html-deploy-repository.postgres.test.ts
  • apps/web/src/** and packages/web-shared/src/** - mechanical import-specifier swaps

Verification performed

  • Confirmed current HEAD 3f5682f3 and trusted summary target before writing.
  • Re-read the active inline comment on packages/trpc/src/mobile.ts:2; the author fixed it in 3f5682f3 by adding "@kilocode/web-shared": "workspace:*" to devDependencies, alongside @kilocode/db/@kilocode/encryption. No duplicate comment posted.
  • Inspected the rebase delta on apps/web/packages/web-shared (only terminal-reason-from-failure.{ts,test.ts}); neither imports via @/, so the removed fallback introduces no dangling alias in newly-rebased files.

Previous review (commit e97b1ee)

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

A 2,010-file mechanical import-specifier refactor (@/ → @kilocode/web-shared/) is consistent and verifiably free of unresolved specifiers; the only finding is an undeclared @kilocode/web-shared dependency in packages/trpc/package.json.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/trpc/src/mobile.ts 2 Imports @kilocode/web-shared without declaring it in packages/trpc/package.json.

Verification performed

  • Fetched PR 7051 at e97b1ee6; no pre-existing inline or issue comments, so no duplicates.
  • Analyzed all four commits: build mapping, mechanical codemod, fallback removal + resolvers, docs/comments.
  • Reconstructed the mechanical diff: every changed line is a specifier swap or an oxfmt rewrap of an affected import/jest.mock call. No logic lines were added or removed.
  • Collected 260 unique @kilocode/web-shared/... specifiers and confirmed each resolves to a file (or index/.d.ts) under packages/web-shared/src.
  • Collected 1,471 unique @/ specifiers across apps/web, apps/ai-gateway, apps/storybook, packages/trpc, packages/web-shared and services/deploy-infra/builder/test-postgres; none resolve to a web-shared-only path, so the removed fallback leaves no dangling @/ reference. The 62 remaining @/ uses inside packages/web-shared are all in tests and all resolve to apps/web.
  • Verified the updated resolvers (apps/web/jest.config.ts, packages/trpc/rollup.config.mjs, apps/web/src/scripts/mcp-catalog/catalog.ts, Storybook alias) and every consumer tsconfig map the new prefix.
  • MCP: services/kilo-mcp/catalog.json is unchanged and no mutation was published, so no MCP write exposure.
  • Memory leaks: the change only rewrites string literals in module-specifier positions; no runtime logic, subscriptions, timers, or allocations change, so no leak is introduced.
  • Markdown: the changed AGENTS.md files contain no images (no HTML <img> tags to flag).
Files Reviewed (2,010 files)
  • packages/web-shared/src/** (1,467 specifier swaps across 325 files)
  • apps/web/src/** (4,044 specifier swaps across 1,648 files)
  • apps/ai-gateway/** (routes, proxy.ts, tsconfig.json, AGENTS.md)
  • apps/storybook/** (.storybook/main.ts, stories/mock data)
  • packages/trpc/** (tsconfig.json, rollup.config.mjs, src/mobile.ts, src/agent-profiles-mobile.ts)
  • apps/web/jest.config.ts, apps/web/tsconfig.json, apps/web/tsconfig.scripts.json, apps/web/src/scripts/mcp-catalog/catalog.ts
  • packages/web-shared/tsconfig.json, packages/web-shared/tsconfig.lib.json, packages/web-shared/AGENTS.md
  • services/deploy-infra/builder/test-postgres/src/html-deploy-repository.postgres.test.ts

Fix these issues in Kilo Cloud


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

Review guidance: REVIEW.md from base branch main

@chrarnoldus
chrarnoldus force-pushed the web-shared-package-imports branch from e97b1ee to 3f5682f Compare October 1, 2026 09:44
Add the explicit specifier next to the existing @/ fallback in every
consumer tsconfig, the web Jest config, the trpc rollup resolver and the
Storybook webpack alias, so imports can move to it.
Mechanical codemod output only, followed by oxfmt. Every `@/` module
specifier that resolved into packages/web-shared/src now uses
`@kilocode/web-shared/`: static imports and re-exports, dynamic import(),
require(), jest.mock/doMock/dontMock/requireActual/requireMock and
`typeof import()` types. Each specifier was resolved with the tsconfig that
owns the importing file (own src first, then packages/web-shared/src).

1,989 files, 5,535 specifiers on 5,533 lines.
@chrarnoldus
chrarnoldus force-pushed the web-shared-package-imports branch from 3f5682f to 02c7516 Compare October 1, 2026 10:56
report-consumer.glanceable.test.ts loads apps/web's active-sessions-list
and mocks the modules it imports by specifier, so the mocks for the
web-shared modules follow the codemod to @kilocode/web-shared/.
`@/*` now maps only to the consumer's own src in apps/web, apps/ai-gateway,
packages/trpc, the web Jest config and the Storybook alias. Inside
packages/web-shared, `@/*` maps to apps/web/src for tests that use apps/web
helpers; tsconfig.lib.json drops it so runtime code cannot use `@/` at all.

The trpc rollup resolver and the MCP catalog import resolver follow
`@kilocode/web-shared/*` into packages/web-shared/src.
Replace the @/ fallback and shadowing rule in packages/web-shared/AGENTS.md
and apps/ai-gateway/AGENTS.md, and point comments that name web-shared
modules at their new specifiers.
packages/trpc imports @kilocode/web-shared/* from source, like the
@kilocode/db and @kilocode/encryption devDependencies it already declares.
Test helpers are about to live in packages/web-shared/src/tests; exclude
them from tsconfig.lib.json and Storybook like apps/web/src/tests.
git mv the apps/web test helpers that web-shared tests use (user,
organization, microdollar-usage, approval, payment-method,
openrouter-models, fake-r2) to packages/web-shared/src/tests/helpers, and
the self-contained constant modules they or web-shared tests need
(lib/auth/constants, lib/organizations/constants,
lib/ai-gateway/latest-model-aliases) to the same paths in web-shared.

Script output only, followed by oxfmt: every specifier that resolved to a
moved file now uses @kilocode/web-shared/<path> (405 specifiers in 326
files).
git mv apps/web/src/lib/balanceCache.ts, which only depends on web-shared
modules and is used by web-shared tests, and point its importers at
@kilocode/web-shared/lib/balanceCache (script output, followed by oxfmt).
…ps/web

Every test case is kept; only its file changes.

- Whole files: kilo-pass-org/consumption.test.ts (its fixtures use the
  apps/web Kilo Pass org service) and model-stats/model-stats-cache.test.ts
  (drives the apps/web /api/models/stats routes) move to the same paths in
  apps/web.
- removeUserFromOrganization cases, and the organizations and usage cases
  that use it, move to organization-member-removal(-usage).test.ts.
- next-auth-options, passkey and platform-admin-grant cases from
  user/server.test.ts move to user/next-auth-options.test.ts, and the
  getProfileRedirectPath cases to user/profile-redirect-path.test.ts.
- The subscription-accounting cases from kilo-pass/issuance.test.ts move
  to kilo-pass/subscription-accounting.test.ts; their shared
  createTestSubscription fixture becomes tests/helpers/kilo-pass.helper.ts.
- The upstream-budget assertion from trpc/control-plane-budget.test.ts
  moves to bounded-service-fetch.test.ts.
packages/web-shared/tsconfig.json no longer maps @/ to apps/web/src, and
`pnpm --filter @kilocode/web-shared typecheck` now also typechecks the tests
with it. An oxlint rule rejects @/ and relative apps/web imports anywhere in
the package.
@chrarnoldus
chrarnoldus force-pushed the web-shared-package-imports branch from 02c7516 to 4f2fc44 Compare October 1, 2026 11:33
@chrarnoldus
chrarnoldus force-pushed the web-shared-package-imports branch 2 times, most recently from 61de6a7 to 4f2fc44 Compare October 1, 2026 12:06
@chrarnoldus chrarnoldus closed this Oct 2, 2026
@chrarnoldus
chrarnoldus deleted the web-shared-package-imports branch October 2, 2026 08:21
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