Tự động hóa chuyển đổi JavaScript sang TypeScript bằng AST và LLM: Pipeline an toàn cho codebase lớn

AI tutorial - IT technology blog
AI tutorial - IT technology blog

Vấn đề thực tế: Điểm nghẽn khi chuyển đổi 250.000 dòng mã

Sáu tháng trước, đội ngũ của chúng tôi rơi vào bế tắc. Chúng tôi cần chuyển đổi toàn bộ backend Node.js/Express legacy—gồm 250.000 dòng JavaScript trải rộng trên khoảng 1.200 file—sang TypeScript strict mode. Năm năm phát triển tính năng dồn dập đã để lại hàng loạt ORM model không được định kiểu, các biến toàn cục ngầm định và vô số thao tác đột biến object tùy tiện. Nếu chuyển đổi hoàn toàn thủ công, sáu kỹ sư sẽ phải mất gần chín tháng làm việc liên tục. Chúng tôi không thể lãng phí ngần ấy thời gian.

Ban đầu, chúng tôi thử dùng các công cụ codemod có sẵn như ts-migrate và airbnb-ts-migrate. Chúng xử lý việc đổi tên file và tạo mã khung cơ bản rất nhanh. Tuy nhiên, chúng cũng rải hơn 14.000 dòng chú thích any và các lệnh chặn lỗi // @ts-expect-error khắp codebase. Dự án biên dịch thành công, nhưng mức độ an toàn tại thời điểm compile gần như bằng không.

Tiếp theo, chúng tôi thử nạp toàn bộ file mã nguồn vào LLM. Thoạt nhìn, đoạn mã sinh ra trông rất gọn gàng, nhưng các lỗi hồi quy tinh vi đã làm hỏng môi trường staging ngay lập tức. Mô hình tự bịa ra (hallucinate) các gói npm chưa từng cài đặt, nhân bản các hàm tiện ích nội bộ và đôi khi tự ý đổi vòng lặp đồng bộ sang bất đồng bộ, khiến thứ tự thực thi bị phá vỡ trong âm thầm. Sau hai tuần chật vật debug test suite chập chờn, chúng tôi đã loại bỏ hoàn toàn hướng tiếp cận đưa cả file cho LLM.

Phân tích nguyên nhân gốc rễ: Tính cứng nhắc về cú pháp và hiện tượng ảo giác theo ngữ cảnh

Quá trình tự động hóa chuyển đổi sẽ thất bại nếu bạn xem việc biến đổi cấu trúc và suy luận kiểu ngữ nghĩa là cùng một nhiệm vụ. Hai công việc này đòi hỏi bộ công cụ hoàn toàn khác nhau:

  • AST Codemod mang lại độ chính xác tuyệt đối mang tính tất định (deterministic). Trình phân tích cú pháp (parser) chuyển đổi mã nguồn thành Cây Cú pháp Trừu tượng (AST), giúp dễ dàng đổi tên khai báo, cập nhật cú pháp module hoặc chèn thêm node mà không hề chạm vào logic runtime. Nhưng AST không thể hiểu được ý định của lập trình viên. Parser AST không thể nhìn vào một req.body không có kiểu rồi suy ra rằng nó nên được định kiểu là CreateUserDTO với trường billingAddress tùy chọn.
  • LLM lại vượt trội ở khả năng suy luận theo ngữ cảnh. Chúng có thể quét các câu truy vấn cơ sở dữ liệu, schema validation và những nơi gọi hàm ở các file lân cận để tạo ra domain model chính xác. Tuy nhiên, LLM bản chất là các mô hình xác suất. Việc giao một file 500 dòng cho mô hình sẽ tạo ra quá nhiều rủi ro làm mất comment, đảo lộn thứ tự câu lệnh hoặc thay đổi nhẹ logic ban đầu.

AST đảm bảo an toàn về mặt cấu trúc nhưng thiếu hiểu biết về mặt ngữ nghĩa. LLM mang lại sự thấu hiểu ngữ nghĩa nhưng không có gì đảm bảo về cấu trúc. Kết hợp cả hai chính là lời giải cho bài toán này.

So sánh các giải pháp: Đo điểm chuẩn các chiến lược chuyển đổi

Chúng tôi đã đánh giá hiệu năng của bốn phương pháp khác nhau trên tập mẫu đại diện gồm 1.200 file trong repository:

Chiến lược Tốc độ xử lý Độ phủ Strict Type Độ an toàn Runtime Công sức thủ công
1. Chuyển đổi thủ công ~3 file / kỹ sư / ngày Cao (~95%) Cao Cực kỳ tốn kém (6 kỹ sư, 9 tháng)
2. Thuần AST / Codemod Ngay tức thì (< 2 phút toàn bộ) Rất thấp (< 15% nếu không tính any) Cao (Giữ nguyên cú pháp) Cao (Đòi hỏi tự định kiểu thủ công rất nhiều)
3. Prompt LLM toàn bộ file ~20 file / giờ Trung bình (~70%) Thấp (Thường xuyên lệch logic) Cao (Phải QA tỉ mỉ từng dòng)
4. Pipeline lai AST + LLM ~120 file / giờ Cao (~94%) Cao nhất (Được compiler xác thực) Tối thiểu (Chỉ cần review pull request)

Giải pháp: Pipeline ba giai đoạn với động cơ kép

Pipeline production của chúng tôi tách biệt việc thao tác cấu trúc khỏi việc tổng hợp kiểu thành ba giai đoạn rõ ràng: Trích xuất bằng AST → Tổng hợp kiểu mục tiêu bằng LLM → Tiêm kiểu bằng AST & Xác thực Compiler.

Giai đoạn 1: Trích xuất xác thực bằng AST

Đầu tiên, một script AST phân tích từng file JavaScript, chuyển đổi các lời gọi require của CommonJS sang câu lệnh import của ES module, đồng thời lập bản đồ mọi khai báo hàm chưa có kiểu. Script chỉ trích xuất phần code gốc của chữ ký hàm và thân hàm trực tiếp mà không thay đổi bất kỳ thành phần nào khác.

Dưới đây là đoạn mã Node.js sử dụng @babel/parser và @babel/traverse để trích xuất các hàm mục tiêu chưa được định kiểu:

const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;

function extractUntypedSignatures(filePath) {
  const code = fs.readFileSync(filePath, 'utf-8');
  const ast = parser.parse(code, {
    sourceType: 'module',
    plugins: ['jsx']
  });

  const targets = [];

  traverse(ast, {
    FunctionDeclaration(path) {
      // Kiểm tra xem các tham số có thiếu khai báo kiểu hay không
      const hasUntypedParams = path.node.params.some(p => !p.typeAnnotation);
      if (hasUntypedParams) {
        targets.push({
          functionName: path.node.id ? path.node.id.name : 'anonymous',
          start: path.node.start,
          end: path.node.end,
          params: path.node.params.map(p => p.name || 'destructured'),
          rawSnippet: code.slice(path.node.start, path.node.end)
        });
      }
    }
  });

  return targets;
}

const targets = extractUntypedSignatures('./src/services/userService.js');
console.log(`Tìm thấy ${targets.length} hàm cần khai báo kiểu.`);

Giai đoạn 2: Tổng hợp kiểu mục tiêu bằng LLM

Thay vì gửi 500 dòng mã hỗn hợp cho LLM, chúng tôi chỉ gửi đoạn mã hàm đã được trích xuất kèm theo ngữ cảnh schema (chẳng hạn như định nghĩa database hoặc chú thích JSDoc). Chúng tôi yêu cầu LLM bắt buộc phải trả về các định nghĩa interface strictly typed và chú thích chữ ký hàm dưới định dạng JSON.

Nhờ giới hạn LLM chỉ xuất ra các định nghĩa kiểu dữ liệu, mô hình hoàn toàn không thể làm sai lệch logic runtime bên dưới.

import json
import requests

def synthesize_types(function_snippet: str, schema_context: str) -> dict:
    prompt = f"""
    Phân tích hàm JavaScript này và trả về các kiểu TypeScript.
    KHÔNG viết lại hoặc thay đổi logic của hàm.
    
    Ngữ cảnh schema:
    {schema_context}
    
    Đoạn mã hàm:
    {function_snippet}
    
    Trả về một đối tượng JSON nghiêm ngặt gồm hai khóa:
    1. "interfaces": mảng chứa các chuỗi interface/type TypeScript.
    2. "params": mảng chứa các đối tượng có chuỗi "name" và "type".
    3. "returnType": chuỗi biểu diễn kiểu trả về.
    """
    
    payload = {
        "model": "gemini-2.5-flash",
        "messages": [{"role": "user", "content": prompt}],
        "response_format": {"type": "json_object"}
    }
    
    response = requests.post(
        "https://api.example.com/v1/chat/completions",
        headers={"Authorization": "Bearer YOUR_API_KEY"},
        json=payload,
        timeout=30
    )
    return json.loads(response.json()["choices"][0]["message"]["content"])

Giai đoạn 3: Vòng lặp tiêm kiểu bằng AST & Xác thực Compiler

Khi LLM trả về các khai báo kiểu, engine AST của chúng tôi sẽ chèn các interface mới vào đầu file và gắn các chú thích kiểu đã suy luận vào tham số của hàm. Thân hàm ban đầu vẫn được giữ nguyên vẹn.

Sau đó, chúng tôi kích hoạt TypeScript compiler để xác thực file vừa được biến đổi theo cấu hình của dự án:

# Kiểm tra kiểu trên toàn bộ repository mà không tạo ra các file đầu ra
npx tsc --noEmit --project tsconfig.json

Nếu tsc phát hiện lỗi (chẳng hạn như thiếu cờ thuộc tính tùy chọn), pipeline sẽ bắt thông báo chẩn đoán của compiler và gửi ngược lại cho LLM để thực hiện lượt tự sửa lỗi. Trong quá trình chuyển đổi của chúng tôi, 82% lỗi kiểu ban đầu đã được giải quyết tự động ngay trong lần thử lại đầu tiên.

Kết quả: Chuyển đổi 250.000 dòng mã trong 18 ngày

Việc triển khai pipeline kết hợp này trên các repository cốt lõi đã mang lại kết quả rõ rệt:

  • Không phát sinh lỗi hồi quy ở runtime: Toàn bộ unit test và integration test hiện có đều vượt qua ngay lần chạy đầu tiên nhờ các rào chắn AST ngăn chặn việc biến đổi sai lệch logic.
  • Độ phủ Strict Type đạt 94%: Chỉ 6% trường hợp động phức tạp—chủ yếu là payload EventEmitter động—cần đến sự can thiệp thủ công của lập trình viên.
  • Tốc độ bàn giao nhanh hơn gấp 10 lần: Dự án ban đầu dự kiến mất chín tháng đã hoàn thành chỉ trong 18 ngày làm việc với vỏn vẹn hai kỹ sư phụ trách review PR.

Khi chuyển đổi các codebase lớn, hãy tránh phụ thuộc hoàn toàn vào các script regex mong manh hoặc để LLM tự do viết lại toàn bộ mã. Hãy để AST kiểm soát cấu trúc và để LLM cung cấp sự thông minh trong việc suy luận kiểu dữ liệu.

Share: