Задача Добавить функции анализа кода

Добавить функции анализа кода

Я много думал о разных механиках в этом направлении, и пока что самым перспективным мне видится использование typescript-сервера. Почему? Потому что мы тогда может оперировать не только самими файлами и читать их в отдельности, но и анализировать их зависимости. К примеру, мы говорим агенту изучить какой-то компонент. Он может просто через специальную тулзу получить сами сущности, что там есть (переменные, функции, импорты, экспорты и т.п.), а так же легко запросить нужные ему дополнительные зависимиости, не думая о том, где они лежат. Но если ему понадобится конечный код нужной зависимости, он легко узнает в каком файле она лежит и прочитает ее.

Тут попутно надо бы инструменты для чтения файлов с указанием диапазона строк. Ведь файл с кодом может быть большим, и если нам ts сразу отдает информацию не только где он лежит, но и в каком диапазоне строк, то нам не обязательно читать весь этот файл.

Ворклоги

Получилось вытащить старые наработки, вот минимальный набор полезных методов.

query analyzeDir {
  analyzeDir(rootDir: "src/components/pages/_App/")
}

{
  "data": {
    "analyzeDir": {
      "rootDir": "src/components/pages/_App",
      "count": 3,
      "files": [
        {
          "path": "src/components/pages/_App/getInitialProps.ts"
        },
        {
          "path": "src/components/pages/_App/index.tsx"
        },
        {
          "path": "src/components/pages/_App/interfaces.ts"
        }
      ]
    }
  }
}

query analyzeFile {
  analyzeFile(path: "src/components/pages/_App/getInitialProps.ts") {
    path
    imports {
      default
      isExternalLibraryImport
      module
      namespace
      resolvedPath
      named {
        name
        alias
      }
    }
    exports
  }
}

{
  "data": {
    "analyzeFile": {
      "path": "src/components/pages/_App/getInitialProps.ts",
      "imports": [
        {
          "default": null,
          "isExternalLibraryImport": false,
          "module": "src/gql/apolloClient",
          "namespace": null,
          "resolvedPath": "src/gql/apolloClient/index.ts",
          "named": [
            {
              "name": "initializeApollo",
              "alias": null
            }
          ]
        },
        {
          "default": "NextApp",
          "isExternalLibraryImport": true,
          "module": "next/app",
          "namespace": null,
          "resolvedPath": null,
          "named": []
        },
        {
          "default": null,
          "isExternalLibraryImport": false,
          "module": "./interfaces",
          "namespace": null,
          "resolvedPath": "src/components/pages/_App/interfaces.ts",
          "named": [
            {
              "name": "AppInitialProps",
              "alias": null
            },
            {
              "name": "MainApp",
              "alias": null
            },
            {
              "name": "NextPageContextCustom",
              "alias": null
            },
            {
              "name": "PageProps",
              "alias": null
            },
            {
              "name": "withWs",
              "alias": null
            }
          ]
        },
        {
          "default": null,
          "isExternalLibraryImport": false,
          "module": "src/helpers/getSiteOrigin",
          "namespace": null,
          "resolvedPath": "src/helpers/getSiteOrigin.ts",
          "named": [
            {
              "name": "getSiteOrigin",
              "alias": null
            }
          ]
        }
      ],
      "exports": [
        {
          "name": "getInitialProps",
          "kind": "Function",
          "line": 13
        }
      ]
    }
  }
}

Но сейчас проблема в том, что тайпскрипт-сервер у нас запускается при каждом запросе.

import path from 'path'
import ts from 'typescript'

export interface ProgramContext {
  program: ts.Program
  checker: ts.TypeChecker
  /** Dir base directory (the folder that contains tsconfig.json). */
  baseDir: string
}

/**
 * Build a TypeScript Program from the project's tsconfig.json.
 * Rebuilt per call — TS's incremental cache is internal to a single Program,
 * and the host process may pick up file changes between requests.
 */
export function getProgram(): ProgramContext {
  const configPath = ts.findConfigFile('./', ts.sys.fileExists, 'tsconfig.json')

  if (!configPath) {
    throw new Error('tsconfig.json not found')
  }

  const baseDir = path.resolve(path.dirname(configPath))
  const configFile = ts.readConfigFile(configPath, ts.sys.readFile)

  const parsed = ts.parseJsonConfigFileContent(
    configFile.config,
    ts.sys,
    baseDir,
  )

  /*
  Вот тут мы создаем тс-сервер и это довольно нагруженная операция,
  она не только жрет много процессора и памяти, но и дает ощутимые задержки
  */
  const program = ts.createProgram({
    rootNames: parsed.fileNames,
    options: parsed.options,
  })

  return {
    program,
    checker: program.getTypeChecker(),
    baseDir,
  }
}

Нам надо уметь держать более долгие инстансы тс-серверов, чтобы не запускать каждый раз с нуля. И тут есть несколько вариантов (по версии opus 4.5):

1. ts.createIncrementalProgram / ts.createWatchProgram

Встроенный механизм TypeScript для инкрементальной компиляции:

const host = ts.createWatchCompilerHost(configPath, {}, ts.sys);
const watchProgram = ts.createWatchProgram(host);
const program = watchProgram.getProgram().getProgram();

Кэширует AST и перекомпилирует только изменённые файлы.

2. ts.server.ProjectService (Language Service)

Это то, что использует VS Code. Полноценный language server с кэшированием:

import * as ts from 'typescript/lib/tsserverlibrary'
const projectService = new ts.server.ProjectService({ ... })

Тяжелее, но даёт доступ к уже запущенному tsserver.

3. Подключение к существующему tsserver через LSP

Если tsserver уже запущен (например, в IDE), можно общаться с ним через stdio или node-ipc. Но это требует знать, где он слушает.

4. Простой кэш на уровне приложения

Хранить program в памяти и пересоздавать только при изменении файлов:

let cachedProgram: ts.Program | null = null;
let lastModified: number = 0;
export function getProgram(baseDir: string) {
  const currentModified = getMaxMtime(baseDir);
  if (cachedProgram && currentModified <= lastModified) {
    return {
      program: cachedProgram,
      checker: cachedProgram.getTypeChecker(),
      baseDir,
    };
  }
}


Рекомендация: Если нужен быстрый результат — вариант 4 (простой кэш). Если нужна полноценная интеграция с IDE — вариант 2 или 3, но это значительно сложнее.

Переделал функцию получения тайпскрипт-сервера. Теперь программа имеет свое время жизни (таймер с момента последнего обращения), чтобы она не висела бесконечно, если к ней нет обращений. И теперь первый запуск на холодную занимает какое-то время в зависимости от проекта, но потом каждый запрос отрабатывается почти мгновенно, при чем в разных запросах. То есть можно сначала запросить список файлов (это один резолвер), а потом анализ какого-то файла (это уже другой резолвер), и анализ прилетит практически мгновенно (как и другие последующие запросы).

import path from 'path'
import ts from 'typescript'

export interface ProgramContext {
  program: ts.Program
  checker: ts.TypeChecker
  /** Dir base directory (the folder that contains tsconfig.json). */
  baseDir: string
}

export interface GetProgramOptions {
  /** Absolute path to project root directory */
  projectRoot: string
  /** Path to tsconfig.json (relative to projectRoot or absolute). Default: 'tsconfig.json' */
  tsconfigPath?: string
}

interface CachedProject {
  program: ts.Program
  /** File versions for incremental rebuild detection */
  fileVersions: Map<string, number>
  /** Timestamp of last access */
  lastAccess: number
  /** Timer for TTL expiration */
  expirationTimer: ReturnType<typeof setTimeout>
}

/** TTL in milliseconds (30 minutes) */
const CACHE_TTL_MS = 30 * 60 * 1000

/** Cache of programs per project root */
const projectCache = new Map<string, CachedProject>()

/**
 * Get file modification time as version number.
 */
function getFileVersion(filePath: string): number {
  try {
    const stat = ts.sys.getModifiedTime?.(filePath)
    return stat ? stat.getTime() : 0
  } catch {
    return 0
  }
}

/**
 * Check if any source files have changed since last build.
 */
function hasChanges(cached: CachedProject): boolean {
  for (const [filePath, version] of cached.fileVersions) {
    if (getFileVersion(filePath) !== version) {
      return true
    }
  }
  return false
}

/**
 * Build or retrieve a cached TypeScript Program for the given project.
 * Uses incremental compilation — only rebuilds when source files change.
 */
export function getProgram(options: GetProgramOptions): ProgramContext {
  const { projectRoot, tsconfigPath = 'tsconfig.json' } = options

  const baseDir = path.resolve(projectRoot)
  const configPath = path.isAbsolute(tsconfigPath)
    ? tsconfigPath
    : path.join(baseDir, tsconfigPath)

  if (!ts.sys.fileExists(configPath)) {
    throw new Error(`tsconfig.json not found at ${configPath}`)
  }

  const cacheKey = configPath

  const cached = projectCache.get(cacheKey)
  if (cached && !hasChanges(cached)) {
    resetExpirationTimer(cacheKey, cached)
    return {
      program: cached.program,
      checker: cached.program.getTypeChecker(),
      baseDir,
    }
  }

  const configFile = ts.readConfigFile(configPath, ts.sys.readFile)

  const parsed = ts.parseJsonConfigFileContent(
    configFile.config,
    ts.sys,
    baseDir,
  )

  const program = ts.createProgram({
    rootNames: parsed.fileNames,
    options: parsed.options,
    oldProgram: cached?.program,
  })

  const fileVersions = new Map<string, number>()
  for (const sourceFile of program.getSourceFiles()) {
    fileVersions.set(sourceFile.fileName, getFileVersion(sourceFile.fileName))
  }

  if (cached) {
    clearTimeout(cached.expirationTimer)
  }

  const newCached: CachedProject = {
    program,
    fileVersions,
    lastAccess: Date.now(),
    expirationTimer: createExpirationTimer(cacheKey),
  }
  projectCache.set(cacheKey, newCached)

  return {
    program,
    checker: program.getTypeChecker(),
    baseDir,
  }
}

/**
 * Reset expiration timer on cache access.
 */
function resetExpirationTimer(cacheKey: string, cached: CachedProject): void {
  clearTimeout(cached.expirationTimer)
  cached.lastAccess = Date.now()
  cached.expirationTimer = createExpirationTimer(cacheKey)
}

/**
 * Create a timer that removes the cache entry after TTL.
 */
function createExpirationTimer(cacheKey: string): ReturnType<typeof setTimeout> {
  return setTimeout(() => {
    projectCache.delete(cacheKey)
  }, CACHE_TTL_MS)
}

/**
 * Clear cached program for a specific project.
 */
export function clearProgramCache(configPath: string): void {
  const key = path.resolve(configPath)
  const cached = projectCache.get(key)
  if (cached) {
    clearTimeout(cached.expirationTimer)
    projectCache.delete(key)
  }
}

/**
 * Clear all cached programs.
 */
export function clearAllProgramCaches(): void {
  for (const cached of projectCache.values()) {
    clearTimeout(cached.expirationTimer)
  }
  projectCache.clear()
}