Khi bạn nhờ AI agent sửa một hàm trong một file, câu hỏi quan trọng nhất không phải là "AI có đủ thông minh để sửa đúng không" mà là "AI có đang nhìn thấy đúng những file cần nhìn thấy không". Đưa thiếu file, agent đoán bừa. Đưa dư file, bạn đốt token và pha loãng sự chú ý của model vào những thứ không liên quan.
Ở bài trước, bạn đã thấy repo map và file summary giúp AI có cái nhìn tổng quan với chi phí token thấp. Nhưng khi agent cần thực sự sửa code, tổng quan là không đủ — nó cần đọc nội dung thật của một số file cụ thể. Vấn đề là: file nào? Phần lớn kỹ sư trả lời câu hỏi này bằng cảm tính — "chắc cũng cần thêm file này, để chắc ăn" — và kết quả là context window bị nhồi đầy những file chỉ liên quan xa xôi.
Bài này trình bày một cách trả lời câu hỏi đó một cách có hệ thống: dùng dependency graph (đồ thị phụ thuộc) của chính codebase để suy ra tập file liên quan, thay vì đoán. Từ import graph ở cấp file, xuống call graph ở cấp hàm, sang test-to-code mapping để tìm đúng bài test làm "trọng tài đúng/sai", và cuối cùng là change-impact analysis (phân tích tác động thay đổi) để biết một thay đổi có thể làm vỡ những gì ở downstream.
Khái niệm cốt lõi: Dependency Graph như một bộ lọc ngữ cảnh
Mọi codebase có cấu trúc module đều ẩn chứa một dependency graph. Mỗi khi file A import hay require từ file B, đó là một cạnh (edge) có hướng từ A tới B trong đồ thị. Đồ thị này đã tồn tại sẵn trong code của bạn — bạn không cần tạo ra nó, chỉ cần trích xuất nó ra bằng công cụ phù hợp.
Giá trị của dependency graph nằm ở việc nó mã hoá độ liên quan (relevance) một cách khách quan, thay cho phán đoán chủ quan. Khi bạn đang sửa file UserService.ts, tập file thực sự liên quan gồm:
- File mục tiêu — chính
UserService.ts, đối tượng đang được thay đổi. - Dependencies (phụ thuộc xuôi) — những file mà
UserService.tsimport vào, vì agent cần hiểu các hàm/class nó đang gọi. - Dependents (phụ thuộc ngược) — những file import
UserService.ts, vì đây là ràng buộc: nếu bạn đổi signature của một hàm public, những file này sẽ vỡ. - Test file bao phủ — các bài test cover
UserService.ts, vì chúng định nghĩa "đúng" nghĩa là gì trong hệ thống của bạn. - Type/interface dùng chung — các file định nghĩa type được
UserService.tstham chiếu, đặc biệt quan trọng với TypeScript hay các ngôn ngữ có type system tĩnh.
Cách tư duy này chính xác hơn nhiều so với các heuristic mơ hồ như "lấy hết file trong module users" hay "lấy hết file .ts trong src/services". Một module có thể chứa 40 file nhưng chỉ 5 file thực sự liên quan tới thay đổi bạn đang làm; ngược lại, file liên quan nhất có khi lại nằm ở một module khác hoàn toàn (ví dụ một shared util) mà cách phân loại theo thư mục không bao giờ bắt được.
Ý tưởng trung tâm: với bất kỳ tác vụ AI nào, bạn có thể suy ra tập file liên quan một cách máy móc (mechanically) từ dependency graph, không cần biết toàn bộ codebase và không cần chọn tay từng file.
Hãy hình dung dependency graph như một "bán kính liên quan" (relevance radius) quanh file mục tiêu. Bán kính 0 chỉ là file mục tiêu. Bán kính 1 thêm các import/importer trực tiếp. Bán kính 2 thêm các dependency của dependency. Phần lớn tác vụ sửa lỗi hay thêm feature nhỏ chỉ cần bán kính 1–2; rất ít tác vụ cần bán kính 3 trở lên — và nếu cần, đó thường là dấu hiệu thay đổi của bạn có phạm vi quá rộng, nên xem xét chia nhỏ task.
Mẹo
Đặt một giới hạn bán kính cứng (hard cap) ngay từ đầu — ví dụ tối đa 2 hop — và log lại số file agent thực tế cần đọc thêm ngoài giới hạn này. Nếu agent liên tục phải "xin" thêm file ở hop 3, đó là dấu hiệu ranh giới module của bạn có coupling (sự phụ thuộc chồng chéo) quá cao, nên refactor tách bạch hơn — không chỉ để tối ưu token mà còn để code dễ maintain hơn.
Static Import Analysis: Truy vết phụ thuộc ở cấp build-time
Static analysis (phân tích tĩnh) đọc source code mà không chạy nó, và dựng dependency graph dựa trên các câu lệnh import/require. Đây là cách nhanh và ổn định nhất để lấy được dependency graph, vì nó không cần chạy chương trình, không cần môi trường runtime, và cho kết quả xác định (deterministic).
Với JavaScript/TypeScript, công cụ phổ biến nhất là madge — nó parse AST (Abstract Syntax Tree) của toàn bộ project và xuất ra một đồ thị dependency, kèm khả năng phát hiện circular dependency (phụ thuộc vòng).
npm install -g madge
madge --extensions ts src/services/UserService.ts
madge --extensions ts --json src > dep-graph.json
Với Python, pydeps cho ra biểu đồ trực quan (thường dùng để review kiến trúc), còn grimp cho phép truy vấn đồ thị bằng code Python — phù hợp hơn khi bạn muốn viết script tự động chọn file:
import grimp
graph = grimp.build_graph("myapp")
downstream = graph.find_downstream_modules(
"myapp.services.user_service", as_package=False
)
upstream = graph.find_upstream_modules("myapp.services.user_service")
print(downstream | upstream | {"myapp.services.user_service"})
Nếu ngôn ngữ hoặc framework bạn dùng không có tool sẵn (ví dụ một DSL nội bộ, hay một hệ thống module không chuẩn), viết một parser AST tuỳ biến (custom) không quá phức tạp — đa số ngôn ngữ hiện đại có thư viện parser sẵn (ts-morph cho TypeScript, ast module built-in cho Python, go/parser cho Go). Bạn chỉ cần tìm các node ImportDeclaration/CallExpression require(...) và ghi lại cạnh của đồ thị.
Điểm mạnh thực sự nằm ở bước tiếp theo: dùng kết quả graph để tự động sinh danh sách file, rồi feed thẳng vào công cụ AI coding, thay vì bạn tự tay gõ từng đường dẫn file. Ví dụ với aider — một CLI coding agent phổ biến hỗ trợ chỉ định file rõ ràng:
#!/usr/bin/env bash
TARGET="src/services/UserService.ts"
FILES=$(madge --extensions ts --json src \
| jq -r --arg t "$TARGET" '
.[$t][],
(to_entries | map(select(.value[] == $t) | .key))[]
' | sort -u)
echo "Files selected for context:"
echo "$FILES"
aider $FILES
Script trên trả về cả dependency (file mà target import) và dependent (file import target) ở bán kính 1, sau đó mở aider với đúng tập file này làm ngữ cảnh làm việc — không hơn, không kém.
Mẹo
Chạy static import analysis như một bước trong CI, không chỉ khi cần — lưu graph vào một file JSON cache và refresh mỗi lần merge vào main. Việc này biến bước "tính dependency graph" từ một tác vụ tốn vài giây mỗi lần dùng thành một tra cứu gần như tức thì, đặc biệt quan trọng với repo lớn có hàng nghìn file.
Call Graph Analysis: Độ chính xác ở cấp hàm
File-level inclusion (đưa toàn bộ file) vẫn còn lãng phí trong nhiều trường hợp. Một file OrderService.ts có thể dài 2.000 dòng với 40 hàm, nhưng thay đổi của bạn chỉ liên quan tới processCheckout và 4 hàm nó gọi trực tiếp. Call graph analysis (phân tích đồ thị gọi hàm) đi sâu hơn import graph một cấp: thay vì hỏi "file nào import file nào", nó hỏi "hàm nào gọi hàm nào".
Call graph có thể được trích xuất bằng:
- AST-based static call graph — parse code, tìm mọi CallExpression, và map tên hàm được gọi về định nghĩa của nó (cần resolve import để biết hàm đó đến từ đâu). Công cụ như ts-morph (TypeScript), jedi (Python), hoặc plugin gọi tới clangd/gopls qua LSP đều hỗ trợ truy vấn "find all references" và "go to definition" — chính là hai nguyên liệu để dựng call graph.
- Runtime/dynamic call graph — chạy chương trình với profiler hoặc code instrumentation, ghi lại chuỗi gọi thực tế. Chính xác hơn với ngôn ngữ động (dynamic dispatch, callback, polymorphism) nhưng chỉ bắt được các đường code path đã thực thi trong lần chạy đó.
Với đa số codebase dùng để feed AI agent, static call graph là đủ và rẻ hơn nhiều. Ví dụ minh hoạ dùng ts-morph để trích xuất tập hàm liên quan tới processCheckout, đi sâu 2 tầng gọi hàm:
// extract-call-subgraph.ts
import { Project } from "ts-morph";
const project = new Project();
project.addSourceFilesAtPaths("src/**/*.ts");
function getCalledFunctions(funcName: string, depth: number, seen = new Set<string>()) {
if (depth === 0 || seen.has(funcName)) return seen;
seen.add(funcName);
for (const sourceFile of project.getSourceFiles()) {
const func = sourceFile.getFunction(funcName) ?? sourceFile.getFunctions()
.find(f => f.getName() === funcName);
if (!func) continue;
// Tìm mọi call expression trong thân hàm, lấy tên hàm được gọi
func.forEachDescendant(node => {
if (node.getKindName() === "CallExpression") {
const calleeName = node.getFirstChild()?.getText();
if (calleeName) getCalledFunctions(calleeName, depth - 1, seen);
}
});
}
return seen;
}
const relevantFunctions = getCalledFunctions("processCheckout", 2);
console.log([...relevantFunctions]);
// -> Set { "processCheckout", "validateInventory", "applyDiscount", "chargeCard" }
Kết quả không phải là danh sách file, mà là danh sách hàm cần đưa cho AI — bước tiếp theo là map ngược từ tên hàm sang vị trí file + dòng, rồi trích riêng phần code đó (hoặc dùng nó để quyết định "file này chỉ cần trích 1 hàm, không cần nguyên file"). Đây chính là cách rút gọn context sâu hơn cả file-level: một file 2.000 dòng có thể chỉ đóng góp 60 dòng thật sự liên quan vào context.
Trong thực tế, nhiều coding agent hiện đại (Claude Code, Cursor, Sourcegraph Cody) đã tích hợp một dạng "symbol-level retrieval" tương tự nguyên lý này khi bạn dùng lệnh kiểu "go to definition" hoặc "find references" trong lúc trò chuyện — hiểu cơ chế bên dưới giúp bạn viết prompt và cấu hình context hiệu quả hơn, vì bạn biết khi nào nên yêu cầu agent "chỉ xem hàm X, đừng đọc nguyên file Y".
Mẹo
Khi hàm mục tiêu là một method trong class lớn (God class), call graph ở cấp hàm quan trọng hơn cả ở cấp file — vì đưa nguyên class 1.500 dòng cho AI trong khi chỉ 3 method liên quan là một trong những nguyên nhân lớn nhất gây lãng phí token mà kỹ sư thường không nhận ra.
Test-to-Code Mapping: Đưa đúng bài test làm trọng tài đúng/sai
Khi AI agent sửa code, câu hỏi "sửa đúng chưa" không thể trả lời chỉ bằng cách đọc lại source code — nó cần một tiêu chuẩn đối chiếu khách quan, và đó chính là test. Nhưng đưa toàn bộ test suite của repo vào context là lãng phí và phản tác dụng: agent sẽ bị phân tán bởi hàng trăm test không liên quan. Test-to-code mapping (ánh xạ test với code) giải quyết việc này bằng cách tìm chính xác những test nào cover file/hàm bạn đang thay đổi.
Có hai cách chính để xây mapping này:
1. Dựa trên coverage data (dữ liệu độ phủ). Hầu hết framework test hiện đại (Jest, pytest-cov, Istanbul/nyc) có thể xuất báo cáo coverage dạng JSON, trong đó có thông tin dòng nào của source file được chạy bởi test nào. Đây là cách chính xác nhất vì nó phản ánh quan hệ thực thi thật, không phụ thuộc vào convention đặt tên.
import json
TARGET_FILE = "src/services/order_service.py"
with open("coverage.json") as f:
data = json.load(f)
covering_tests = set()
file_data = data["files"].get(TARGET_FILE, {})
for line, contexts in file_data.get("contexts", {}).items():
for ctx in contexts:
test_id = ctx.split("|")[0] # ví dụ "tests/test_order_service.py::test_apply_discount"
if test_id:
covering_tests.add(test_id)
print(f"Tests covering {TARGET_FILE}:")
for t in sorted(covering_tests):
print(f" - {t}")
2. Dựa trên naming convention (quy ước đặt tên). Khi không có coverage data sẵn (ví dụ project chưa cấu hình, hoặc bạn cần một cách nhanh không tốn thời gian chạy test suite), quy ước đặt tên file thường đủ dùng: order_service.py → test_order_service.py, hay UserService.ts → UserService.test.ts / UserService.spec.ts. Cách này nhanh nhưng có thể bỏ sót test tích hợp (integration test) cover nhiều file cùng lúc mà không nằm cùng tên với bất kỳ file nào trong số đó.
TARGET="src/services/UserService.ts"
BASENAME=$(basename "$TARGET" .ts)
find . -type f \( -name "${BASENAME}.test.ts" -o -name "${BASENAME}.spec.ts" \) \
-not -path "*/node_modules/*"
Cách thực dụng nhất là kết hợp cả hai: dùng coverage data khi có (chính xác hơn), fallback sang naming convention khi không có. Khi đưa test vào context cho AI, luôn kèm rõ vai trò của nó trong prompt — ví dụ "đây là test hiện có cho hàm bạn đang sửa, đảm bảo thay đổi của bạn không làm test này fail, và nếu behavior thay đổi có chủ đích, hãy cập nhật test tương ứng" — để agent không nhầm test là code cần sửa.
Đối với QA engineer, kỹ thuật này cũng hữu ích ngược lại: khi review một PR, bạn có thể dùng mapping này để nhanh chóng liệt kê "những test nào đang bảo vệ đoạn code vừa đổi" và đánh giá độ phủ có đủ tin cậy hay không, trước khi cả agent hay con người merge.
Mẹo
Nếu team chưa có coverage-context data (--cov-context trong pytest, hay tương đương), đừng cố gắng setup phức tạp ngay — bắt đầu bằng naming convention, nó đủ tốt cho 80% trường hợp trong codebase có convention đặt tên rõ ràng, và bạn có thể nâng cấp lên coverage-based mapping sau khi thấy giá trị thực tế.
Change-Impact Analysis: Tìm trước những gì sẽ vỡ
Change-impact analysis (phân tích tác động thay đổi) là dùng dependency graph và call graph theo chiều ngược so với các phần trước: thay vì hỏi "file này cần gì để hiểu", nó hỏi "nếu tôi đổi file này, những gì khác sẽ bị ảnh hưởng?". Đây chính là khái niệm "blast radius" (bán kính vụ nổ) — một thuật ngữ mượn từ security/SRE, nhưng áp dụng cực tốt vào việc đánh giá rủi ro thay đổi code.
Về bản chất kỹ thuật, đây là bài toán tìm tất cả dependents (và dependents của dependents) của một file, cộng với mọi test mapping tới các file đó — tức là kết hợp static import analysis (theo chiều ngược) với test-to-code mapping đã nói ở trên.
import json
import subprocess
def get_changed_files():
out = subprocess.run(
["git", "diff", "--name-only", "origin/main...HEAD"],
capture_output=True, text=True
)
return [f for f in out.stdout.splitlines() if f.endswith((".ts", ".tsx"))]
def load_dep_graph(path="dep-graph.json"):
with open(path) as f:
return json.load(f) # { "fileA.ts": ["fileB.ts", ...], ... } (A -> deps của A)
def find_dependents(graph, target, seen=None):
"""Tìm ngược: file nào phụ thuộc vào target, đi tối đa 2 tầng."""
seen = seen or set()
for f, deps in graph.items():
if target in deps and f not in seen:
seen.add(f)
find_dependents(graph, f, seen)
return seen
def find_tests_for(files, test_map):
tests = set()
for f in files:
tests.update(test_map.get(f, []))
return tests
if __name__ == "__main__":
graph = load_dep_graph()
test_map = json.load(open("test-map.json")) # từ bước test-to-code mapping
changed = get_changed_files()
impacted_files = set(changed)
for f in changed:
impacted_files |= find_dependents(graph, f)
impacted_tests = find_tests_for(impacted_files, test_map)
print(f"Changed files: {len(changed)}")
print(f"Potentially impacted files (blast radius): {len(impacted_files)}")
print(f"Tests that MUST pass before merge: {len(impacted_tests)}")
for t in sorted(impacted_tests):
print(f" - {t}")
Script này chạy được ở cả hai mục đích:
- Trước khi commit/PR (dành cho kỹ sư): biết chắc những test nào cần chạy lại và những file nào cần review kỹ hơn, mà không phải chạy toàn bộ test suite (rất hữu ích với monorepo lớn, khi full test suite tốn 40 phút).
- Đánh giá rủi ro (dành cho QA/PM): "blast radius" của một PR — nếu con số impacted files quá lớn so với kích thước thay đổi (ví dụ sửa 1 dòng nhưng blast radius là 80 file), đó là dấu hiệu file đó là một "hot spot" cần review kỹ hơn bình thường, cần thêm regression test, hoặc cần chia PR nhỏ hơn.
Change-impact analysis cũng là input rất tốt cho AI agent làm code review: thay vì yêu cầu agent "review PR này", bạn có thể yêu cầu agent review PR cùng với danh sách các file/test bị ảnh hưởng gián tiếp, để nó chủ động kiểm tra các điểm breaking change ở downstream mà một review chỉ nhìn diff không bao giờ thấy được.
Mẹo
Đặt một ngưỡng cảnh báo (threshold) trên số lượng impacted files — ví dụ nếu blast radius > 20 file cho một PR nhỏ hơn 50 dòng thay đổi, tự động gắn label "high-impact" hoặc yêu cầu thêm reviewer. Đây là cách biến change-impact analysis từ một công cụ "tra cứu khi cần" thành một lớp bảo vệ tự động trong quy trình review.
Tích hợp thực tế: Đưa Dependency-Aware Context vào workflow hằng ngày
Kỹ thuật hay nhất chỉ có giá trị khi nó được gắn vào workflow thực tế của từng vai trò, không nằm im trong một script chỉ chạy khi ai đó nhớ tới. Dưới đây là cách tích hợp cho ba nhóm persona chính.
Với kỹ sư dùng Cursor / Claude Code / aider: mục tiêu là biến quy trình "chọn file cho context" từ thủ công thành bán tự động. Với Cursor, bạn có thể maintain một quy tắc trong .cursorrules hoặc dùng @file reference dựa trên output của script phân tích dependency:
@src/services/UserService.ts # target file
@src/services/AuthService.ts # dependency (imported by target)
@src/types/User.ts # shared type
@src/controllers/UserController.ts # dependent (imports target)
@src/services/UserService.test.ts # test mapping (correctness reference)
Với Claude Code, bạn có thể gắn logic này vào một hook chạy trước session, tự động in ra gợi ý file liên quan mỗi khi bạn bắt đầu làm việc trên một file:
#!/usr/bin/env bash
TARGET="$1"
if [ -z "$TARGET" ]; then exit 0; fi
echo "Suggested context for editing $TARGET:"
madge --extensions ts --json src 2>/dev/null | \
jq -r --arg t "$TARGET" '
.[$t][]?,
(to_entries | map(select(.value[]? == $t) | .key))[]?
' | sed 's/^/ - /'
TESTFILE="${TARGET%.ts}.test.ts"
[ -f "$TESTFILE" ] && echo " - $TESTFILE (correctness reference)"
Với QA engineer: dùng change-impact analysis để scope test plan chính xác hơn thay vì chạy full regression cho mọi PR. Khi nhận một PR để test, chạy script impact-check.py ở phần trước để biết chính xác module nào cần test tay kỹ hơn, và những automation test nào bắt buộc phải xanh trước khi ký duyệt. Điều này đặc biệt giá trị khi lịch release gấp và không có thời gian chạy full regression suite.
Với product manager: không cần hiểu chi tiết kỹ thuật của dependency graph, nhưng cần một bản tóm tắt dễ đọc về "PR này động tới bao nhiêu chỗ, rủi ro cỡ nào". Một wrapper script đơn giản có thể chuyển output kỹ thuật thành ngôn ngữ nghiệp vụ:
#!/usr/bin/env bash
python impact-check.py > /tmp/impact.txt
CHANGED=$(grep "Changed files:" /tmp/impact.txt | grep -o '[0-9]*')
IMPACTED=$(grep "blast radius" /tmp/impact.txt | grep -o '[0-9]*' | head -1)
TESTS=$(grep "MUST pass" /tmp/impact.txt | grep -o '[0-9]*')
echo "== Impact Summary =="
echo "Files changed directly : $CHANGED"
echo "Files potentially affected : $IMPACTED"
echo "Tests required before merge : $TESTS"
if [ "$IMPACTED" -gt 20 ]; then
echo ""
echo "⚠️ Wide-reaching change — recommend extra review time and a staged rollout."
else
echo ""
echo "✅ Contained change — standard review process should suffice."
fi
Ba mảnh ghép này — Cursor context list, Claude Code pre-session hook, và PM-friendly impact summary — cùng chạy trên một nguồn dữ liệu duy nhất: dependency graph và test mapping đã tính sẵn. Đây là điểm quan trọng nhất của phần tích hợp: bạn chỉ cần tính dependency graph một lần, cache lại, rồi để mỗi persona tiêu thụ nó theo định dạng phù hợp với công việc của họ — kỹ sư cần danh sách file, QA cần danh sách test, PM cần một con số và một nhận định rủi ro.
Mẹo
Đừng để mỗi người tự chạy madge/grimp/coverage export riêng lẻ mỗi khi cần — commit các script này vào repo dưới scripts/context/, chạy graph generation như một job CI định kỳ (hoặc post-merge vào main), và để mọi tool tiêu thụ chung một file dep-graph.json đã cache. Điều này đảm bảo toàn team dùng cùng một "sự thật" về dependency, tránh tình trạng mỗi người có một bản context khác nhau cho cùng một task.
Tổng kết
Targeted file inclusion biến việc chọn context từ một quyết định cảm tính thành một phép tính có thể tái lập: dependency graph cho bạn biết file nào liên quan ở cấp module, call graph thu hẹp xuống cấp hàm, test-to-code mapping đảm bảo agent luôn có "trọng tài đúng/sai" đi kèm, và change-impact analysis lật ngược bài toán để trả lời câu hỏi "điều gì sẽ vỡ". Khi bốn kỹ thuật này được cache và tự động hoá, mọi persona trong vòng đời phát triển — kỹ sư, QA, PM — đều có thể lấy ra đúng góc nhìn họ cần từ cùng một nguồn dữ liệu, với chi phí token và thời gian thấp hơn hẳn so với cách "đưa cả module vào context cho chắc".