Задача Добавить функции анализа кода
Добавить функции анализа кода
Я много думал о разных механиках в этом направлении, и пока что самым перспективным мне видится использование 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()
}