Task 2. Xây dựng dịch vụ parser CPG tăng dần
1Mục tiêu¶
Task 2 xây dựng Parser Service xử lý từng file Python độc lập và sinh Code Property Graph ở dạng event. Parser dùng module ast chuẩn của Python để trích xuất AST nodes, quan hệ AST_CHILD, cạnh điều khiển CFG_NEXT, cạnh dữ liệu DFG_REACHES, call edges và metadata của file.
Service cũng chịu trách nhiệm cho incremental behavior: tính content_hash, đối chiếu SQLite state, bỏ qua file không đổi và chỉ commit state sau khi writer hoặc Kafka producer đã flush thành công. Notebook sử dụng dry-run JSONL để kiểm chứng contract trước khi đưa cùng event stream sang Kafka ở Task 3.
2Thiết kế Parser Service¶
Parser Service nằm giữa eligible manifest của Task 1 và event streams của Task 3. Thiết kế theo hướng xử lý tuần tự từng file để giữ memory bounded, đồng thời dùng stable identifiers để các lần replay không tạo entity mới cho cùng một cấu trúc logic.
| Thành phần | Implementation hiện tại | Vai trò |
|---|---|---|
| Discovery/manifest adapter | GitSourceRepository, ManifestWriter | Cung cấp danh sách file hợp lệ và metadata discovery |
| CPG parser | CpgParser, ast_builder, cfg_builder, dfg_builder, call_builder | Trích xuất AST, CFG, DFG và call relations |
| Identifier generator | IdentifierGenerator | Tạo file_id, node_id, edge_id, event_id và content_hash ổn định |
| Event validator | EventValidator | Kiểm tra JSON Schema trước khi ghi hoặc publish |
| Event writer/producer | JsonlEventWriter, KafkaEventProducer | Ghi JSONL dry-run hoặc publish Kafka |
| SQLite state store | SqliteStateStore | Lưu content hash, graph IDs, parser/schema version để hỗ trợ incremental skip |
Sơ đồ CPG nhỏ chỉ minh họa các loại quan hệ chính. Graph thực tế của mỗi file được sinh từ AST và builders hiện tại, sau đó serialize thành node/edge/metadata/error events.
import os
import json
import shutil
import subprocess
import hashlib
import sqlite3
from pathlib import Path
from collections import Counter
import jsonschema
# Resolve PROJECT_ROOT
PROJECT_ROOT = Path(
subprocess.run(
["git", "rev-parse", "--show-toplevel"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
)
# Helper to run CLI commands and check return codes
def run_command(
args: list[str],
*,
expected_return_codes: set[int] | None = None,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
if expected_return_codes is None:
expected_return_codes = {0}
result = subprocess.run(
args,
capture_output=True,
text=True,
cwd=str(PROJECT_ROOT),
env=env
)
if result.returncode not in expected_return_codes:
print("STDOUT:", result.stdout)
print("STDERR:", result.stderr)
raise RuntimeError(
f"Command {args} failed with exit code {result.returncode}. Expected one of {expected_return_codes}"
)
return result
# Isolated paths for demonstration - shifted to runtime temporary workspace (workspace/tmp/notebook/)
PARSER_SMOKE_STATE = PROJECT_ROOT / "workspace/tmp/notebook/parser_smoke.sqlite3"
NOTEBOOK_OUTPUT_DIRECTORY = PROJECT_ROOT / "workspace/tmp/notebook-parser"
SCHEMA_DIR = PROJECT_ROOT / "schemas"
SOURCE_REPOSITORY = PROJECT_ROOT / "workspace/source/transformers-pr-agent"
# Reset isolated demonstration state and dirs in temporary workspace
NOTEBOOK_STATE_DIRECTORY = PROJECT_ROOT / "workspace/tmp/notebook"
if NOTEBOOK_STATE_DIRECTORY.exists():
shutil.rmtree(NOTEBOOK_STATE_DIRECTORY, ignore_errors=True)
NOTEBOOK_STATE_DIRECTORY.mkdir(parents=True, exist_ok=True)
if NOTEBOOK_OUTPUT_DIRECTORY.exists():
shutil.rmtree(NOTEBOOK_OUTPUT_DIRECTORY, ignore_errors=True)
NOTEBOOK_OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
print("PROJECT_ROOT:", PROJECT_ROOT)
print("Isolated state DB:", PARSER_SMOKE_STATE)
print("Isolated output dir:", NOTEBOOK_OUTPUT_DIRECTORY / "smoke")
print("Schemas directory:", SCHEMA_DIR)
print("SOURCE_REPOSITORY:", SOURCE_REPOSITORY)PROJECT_ROOT: /home/phat/AI_Project/lab04-cpg-streaming
Isolated state DB: /home/phat/AI_Project/lab04-cpg-streaming/workspace/tmp/notebook/parser_smoke.sqlite3
Isolated output dir: /home/phat/AI_Project/lab04-cpg-streaming/workspace/tmp/notebook-parser/smoke
Schemas directory: /home/phat/AI_Project/lab04-cpg-streaming/schemas
SOURCE_REPOSITORY: /home/phat/AI_Project/lab04-cpg-streaming/workspace/source/transformers-pr-agent
3Runtime cô lập¶
Notebook dùng smoke scope gồm ba file để thời gian chạy ngắn và output dễ đọc. Khi chạy final scope, Parser Service nhận toàn bộ 2.963 eligible parser inputs từ manifest của Task 1.
Các state DB và JSONL output được đặt trong workspace/tmp/notebook/ để việc execute notebook không làm thay đổi state runtime chính của các task khác.
4Thực thi mẫu smoke¶
Cell dưới đây chạy CLI chính thức parse-repository ở dry-run mode. Dry-run vẫn đi qua discovery, parser, schema validation và writer flush; khác biệt duy nhất là event được ghi thành JSONL thay vì publish Kafka.
# Initial parse run on 3 files using the official CLI
env = {**os.environ, "PARSER_STATE_DB": str(PARSER_SMOKE_STATE)}
result = run_command(
[
"uv", "run", "lab04", "parse-repository",
"--scope", "smoke",
"--limit", "3",
"--dry-run",
"--clean-output",
"--out-dir", str(NOTEBOOK_OUTPUT_DIRECTORY / "smoke"),
],
env=env
)
print("CLI command executed successfully!")
print(result.stdout)CLI command executed successfully!
Parsing repository (scope=smoke, limit=3, dry_run=True)...
Repository run completed. Summary:
{'discovered': 5, 'eligible': 3, 'processed': 3, 'skipped_unchanged': 0, 'failed': 0, 'node_events': 4699, 'edge_events': 5453, 'metadata_events': 3, 'error_events': 0, 'duration_ms': 115316}
5Xác minh event output¶
Sau parse run, notebook đếm số event JSONL theo từng loại và xem một số sample đại diện. Các sample này cho thấy event envelope có schema_version, event_time, stable IDs, file_id, content_hash và payload nghiệp vụ.
def count_jsonl(path: Path) -> int:
if not path.exists():
return 0
with path.open(encoding="utf-8") as handle:
return sum(1 for _ in handle)
smoke_out = NOTEBOOK_OUTPUT_DIRECTORY / "smoke"
counts = {
name: count_jsonl(smoke_out / f"{name}.jsonl")
for name in ["nodes", "edges", "metadata", "errors"]
}
for name, count in counts.items():
print(f"{name}: {count}")
nodes: 4699
edges: 5453
metadata: 3
errors: 0
with (smoke_out / "nodes.jsonl").open(encoding="utf-8") as handle:
sample_node = json.loads(next(handle))
print(json.dumps(sample_node, indent=2, ensure_ascii=False))
{
"schema_version": "1.0",
"event_id": "dcffdf7d31cb302a5da1c9e45b427426b0ba9d7c6394991c524af056ba613482",
"event_type": "NODE_UPSERT",
"event_time": "2026-07-25T08:25:24Z",
"repository_id": "huggingface/transformers-pr-agent",
"commit_sha": "458c957fa1e8851825cd799f5d030876f0644194",
"file_id": "526ba644702927c3bd46145a2903e7622db08269cc30575f09aa80925f5619bc",
"file_path": "src/transformers/__init__.py",
"content_hash": "995c4799e2dbed014814fd85485e04a46b53bb4c74512f97edb0502d60917e65",
"parser_version": "1.0.0",
"node": {
"node_id": "6e65fb21b5c7c88bc6d1103d9526384516bf980081cb166075fb31d1e3fac364",
"node_type": "Module",
"name": "Module",
"qualified_name": "Module",
"ast_path": "Module",
"line_start": null,
"column_start": null,
"line_end": null,
"column_end": null,
"properties": {}
}
}
Evidence: edge event và phân bố loại cạnh
Edge output cho biết parser không chỉ tạo AST hierarchy mà còn tạo các quan hệ điều khiển, dữ liệu và call graph. Phân bố edge type giúp kiểm tra nhanh builder nào đã phát sinh dữ liệu trong smoke run.
edge_types = Counter()
first_edge = None
with (smoke_out / "edges.jsonl").open(encoding="utf-8") as handle:
for line in handle:
event = json.loads(line)
first_edge = first_edge or event
edge_types[event["edge"]["edge_type"]] += 1
print("Edge types:")
for edge_type, count in edge_types.most_common():
print(f"{edge_type}: {count}")
print("\nSample edge:")
print(json.dumps(first_edge, indent=2, ensure_ascii=False))
Edge types:
AST_CHILD: 4461
CFG_NEXT: 520
DFG_DEF_USE: 264
CALLS: 156
CFG_RETURN: 26
CFG_TRUE: 10
CFG_FALSE: 10
CFG_LOOP_BODY: 2
CFG_LOOP_EXIT: 2
CFG_LOOP_BACK: 2
Sample edge:
{
"schema_version": "1.0",
"event_id": "9184dc4787c0e973af0d652051a524e59bb459d5cf86713bb2550131e2c13e52",
"event_type": "EDGE_UPSERT",
"event_time": "2026-07-25T08:25:24Z",
"repository_id": "huggingface/transformers-pr-agent",
"commit_sha": "458c957fa1e8851825cd799f5d030876f0644194",
"file_id": "526ba644702927c3bd46145a2903e7622db08269cc30575f09aa80925f5619bc",
"file_path": "src/transformers/__init__.py",
"content_hash": "995c4799e2dbed014814fd85485e04a46b53bb4c74512f97edb0502d60917e65",
"parser_version": "1.0.0",
"edge": {
"edge_id": "2fc10e5bb915d017535d742086e36ca1de7cf8eaecef0a5cc1b8bc6f3c1a8448",
"source_id": "6e65fb21b5c7c88bc6d1103d9526384516bf980081cb166075fb31d1e3fac364",
"target_id": "ba00e64dbb110261ef4efdb669e5945d8c9d5e439e7c44c3bbe06ed18201cfb3",
"edge_type": "AST_CHILD",
"properties": {
"field": "body",
"child_index": 0
}
}
}
Evidence: metadata event
Metadata tóm tắt kích thước file, số dòng, số hàm, số class, số node/edge và trạng thái parse. Topic metadata này là đầu vào cho nhánh Spark/MongoDB ở Task 5.
with (smoke_out / "metadata.jsonl").open(encoding="utf-8") as handle:
sample_metadata = json.loads(next(handle))
print(json.dumps(sample_metadata, indent=2, ensure_ascii=False))
{
"schema_version": "1.0",
"event_id": "c769c9e615bcfe9d98f5664c6e94ecf40ac9adebf97d11d67c97c25d71f91dcd",
"event_type": "FILE_METADATA_UPSERT",
"event_time": "2026-07-25T08:25:24Z",
"repository_id": "huggingface/transformers-pr-agent",
"commit_sha": "458c957fa1e8851825cd799f5d030876f0644194",
"file_id": "526ba644702927c3bd46145a2903e7622db08269cc30575f09aa80925f5619bc",
"file_path": "src/transformers/__init__.py",
"content_hash": "995c4799e2dbed014814fd85485e04a46b53bb4c74512f97edb0502d60917e65",
"parser_version": "1.0.0",
"metadata": {
"size_bytes": 41203,
"line_count": 863,
"function_count": 4,
"class_count": 0,
"import_count": 308,
"node_count": 1971,
"edge_count": 2417,
"parse_duration_ms": 36,
"parse_status": "SUCCESS",
"parser": "python.ast"
}
}
6Xác minh schema, determinism và incremental behavior¶
Các bước tiếp theo kiểm tra bốn thuộc tính quan trọng của Parser Service: event hợp lệ theo JSON Schema, stable IDs/content hashes lặp lại giữa hai lần chạy độc lập, file không đổi được skip bằng SQLite state, và lỗi cú pháp được route thành PARSER_ERROR thay vì graph events.
# Schema validation helper using root schemas
def validate_event_with_schema(event: dict, schema_name: str):
schema_path = SCHEMA_DIR / schema_name
with open(schema_path, "r", encoding="utf-8") as f:
schema = json.load(f)
jsonschema.Draft202012Validator(schema).validate(event)
# Validate all nodes, edges, and metadata
event_types_checked = set()
# Check Node schemas
with (smoke_out / "nodes.jsonl").open(encoding="utf-8") as f:
for line in f:
event = json.loads(line)
validate_event_with_schema(event, "node-event.schema.json")
event_types_checked.add(event["event_type"])
# Check Edge schemas
with (smoke_out / "edges.jsonl").open(encoding="utf-8") as f:
for line in f:
event = json.loads(line)
validate_event_with_schema(event, "edge-event.schema.json")
event_types_checked.add(event["event_type"])
# Check Metadata schemas
with (smoke_out / "metadata.jsonl").open(encoding="utf-8") as f:
for line in f:
event = json.loads(line)
validate_event_with_schema(event, "metadata-event.schema.json")
event_types_checked.add(event["event_type"])
print("JSON Schema validation using root schemas directory PASSED successfully!")
print("Validated event types:", list(event_types_checked))
JSON Schema validation using root schemas directory PASSED successfully!
Validated event types: ['NODE_UPSERT', 'EDGE_UPSERT', 'FILE_METADATA_UPSERT']
# Stable IDs and content hashes determinism verification
determinism_primary_db = PROJECT_ROOT / "workspace/tmp/notebook/parser_determinism_primary.sqlite3"
determinism_reference_db = PROJECT_ROOT / "workspace/tmp/notebook/parser_determinism_reference.sqlite3"
if determinism_primary_db.exists():
determinism_primary_db.unlink()
if determinism_reference_db.exists():
determinism_reference_db.unlink()
# Copy the smoke DB to primary determinism DB
shutil.copy(PARSER_SMOKE_STATE, determinism_primary_db)
state_a = NOTEBOOK_OUTPUT_DIRECTORY / "determinism-primary"
state_b = NOTEBOOK_OUTPUT_DIRECTORY / "determinism-reference"
# Copy smoke outputs to determinism-primary
if state_a.exists():
shutil.rmtree(state_a)
shutil.copytree(NOTEBOOK_OUTPUT_DIRECTORY / "smoke", state_a)
env_b = {**os.environ, "PARSER_STATE_DB": str(determinism_reference_db)}
run_command(
[
"uv", "run", "lab04", "parse-repository",
"--scope", "smoke",
"--limit", "3",
"--dry-run",
"--clean-output",
"--out-dir", str(state_b),
],
env=env_b
)
def get_ids_and_hashes(out_dir_path: Path):
node_ids = set()
edge_ids = set()
file_ids = set()
hashes = set()
with (out_dir_path / "nodes.jsonl").open(encoding="utf-8") as f:
for line in f:
evt = json.loads(line)
node_ids.add(evt["node"]["node_id"])
with (out_dir_path / "edges.jsonl").open(encoding="utf-8") as f:
for line in f:
evt = json.loads(line)
edge_ids.add(evt["edge"]["edge_id"])
with (out_dir_path / "metadata.jsonl").open(encoding="utf-8") as f:
for line in f:
evt = json.loads(line)
file_ids.add(evt["file_id"])
hashes.add(evt["content_hash"])
return node_ids, edge_ids, file_ids, hashes
node_ids_a, edge_ids_a, file_ids_a, hashes_a = get_ids_and_hashes(state_a)
node_ids_b, edge_ids_b, file_ids_b, hashes_b = get_ids_and_hashes(state_b)
assert node_ids_a == node_ids_b, "Node IDs are not deterministic"
assert edge_ids_a == edge_ids_b, "Edge IDs are not deterministic"
assert file_ids_a == file_ids_b, "File IDs are not deterministic"
assert hashes_a == hashes_b, "Content hashes are not deterministic"
print("Stable IDs and content hashes determinism verification A/B PASSED successfully!")Stable IDs and content hashes determinism verification A/B PASSED successfully!
# Incremental skip verification (re-parsing same repo with same state DB)
env_skip = {**os.environ, "PARSER_STATE_DB": str(PARSER_SMOKE_STATE)}
skip_out = NOTEBOOK_OUTPUT_DIRECTORY / "unchanged"
result_skip = run_command(
[
"uv", "run", "lab04", "parse-repository",
"--scope", "smoke",
"--limit", "3",
"--dry-run",
"--clean-output",
"--out-dir", str(skip_out),
],
env=env_skip
)
print(result_skip.stdout)
assert count_jsonl(skip_out / "nodes.jsonl") == 0, "No new nodes should be generated"
assert count_jsonl(skip_out / "edges.jsonl") == 0, "No new edges should be generated"
assert count_jsonl(skip_out / "metadata.jsonl") == 0, "No new metadata should be generated"
print("Incremental unchanged-file skip verification PASSED successfully!")Parsing repository (scope=smoke, limit=3, dry_run=True)...
Repository run completed. Summary:
{'discovered': 5, 'eligible': 3, 'processed': 0, 'skipped_unchanged': 3, 'failed': 0, 'node_events': 0, 'edge_events': 0, 'metadata_events': 0, 'error_events': 0, 'duration_ms': 1826}
Incremental unchanged-file skip verification PASSED successfully!
# Parser error verification using broken syntax fixture
parser_error_db = PROJECT_ROOT / "workspace/tmp/notebook/parser_error.sqlite3"
if parser_error_db.exists():
parser_error_db.unlink()
# Copy fixture to target repo to comply with CLI path resolution logic
target_fixture_dir = SOURCE_REPOSITORY / "tests/fixtures"
target_fixture_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(PROJECT_ROOT / "tests/fixtures/broken_syntax.py", target_fixture_dir / "broken_syntax.py")
env_err = {**os.environ, "PARSER_STATE_DB": str(parser_error_db)}
syntax_error_out = NOTEBOOK_OUTPUT_DIRECTORY / "syntax-error"
# Run command expecting return code 1
result_err = run_command(
[
"uv", "run", "lab04", "parse-file",
"--file", "tests/fixtures/broken_syntax.py",
"--dry-run",
"--clean-output",
"--out-dir", str(syntax_error_out),
],
expected_return_codes={1},
env=env_err
)
# Cleanup copied fixture immediately
if (target_fixture_dir / "broken_syntax.py").exists():
(target_fixture_dir / "broken_syntax.py").unlink()
# --- Verification & Presentation ---
# 1. Assert invalid syntax file was rejected (returncode == 1)
assert result_err.returncode == 1, "CLI must return exit code 1 on parse failure"
# 2. Verify errors.jsonl exists and conforms to schema
assert (syntax_error_out / "errors.jsonl").exists(), "errors.jsonl must be created"
with (syntax_error_out / "errors.jsonl").open(encoding="utf-8") as f:
err_event = json.loads(next(f))
# Assert event type is PARSER_ERROR
assert err_event["event_type"] == "PARSER_ERROR", "Event type must be PARSER_ERROR"
# Validate schema
validate_event_with_schema(err_event, "error-event.schema.json")
# 3. Assert error event contains correct file path and content hash
with open(PROJECT_ROOT / "tests/fixtures/broken_syntax.py", "rb") as f:
expected_content = f.read()
expected_hash = hashlib.sha256(expected_content).hexdigest()
assert err_event["file_path"] == "tests/fixtures/broken_syntax.py", f"Incorrect file path: {err_event['file_path']}"
assert err_event["content_hash"] == expected_hash, f"Incorrect content hash: {err_event['content_hash']}"
# 4. Assert no graph success state was committed to SQLite state store
conn = sqlite3.connect(parser_error_db)
cursor = conn.cursor()
cursor.execute("SELECT * FROM file_state WHERE file_path LIKE '%broken_syntax.py%'")
rows = cursor.fetchall()
conn.close()
assert len(rows) == 0, "Error: Graph state must not be committed to SQLite for files with syntax errors!"
# 5. Assert no node/edge events were generated
assert not (syntax_error_out / "nodes.jsonl").exists(), "nodes.jsonl must not be created for error file"
assert not (syntax_error_out / "edges.jsonl").exists(), "edges.jsonl must not be created for error file"
# Print outputs as specified
print("Parser execution result:")
print("FAILED AS EXPECTED")
print()
print("Verification result:")
print("PASSED")
print()
print("## Kết quả Parser")
print("- Input: `tests/fixtures/broken_syntax.py`")
print("- Parser status: `FAILED`")
print("- CLI exit code: `1`")
print("- Expected behavior: `Yes`")
print()
print("## Kết quả Verification")
print("[PASS] Parser rejected the invalid Python file as expected.")
print("[PASS] CLI returned a non-zero exit code as expected.")
print("[PASS] PARSER_ERROR event was generated.")
print("[PASS] Error event passed JSON Schema validation.")
print("[PASS] No successful graph state was committed.")Parser execution result:
FAILED AS EXPECTED
Verification result:
PASSED
## Kết quả Parser
- Input: `tests/fixtures/broken_syntax.py`
- Parser status: `FAILED`
- CLI exit code: `1`
- Expected behavior: `Yes`
## Kết quả Verification
[PASS] Parser rejected the invalid Python file as expected.
[PASS] CLI returned a non-zero exit code as expected.
[PASS] PARSER_ERROR event was generated.
[PASS] Error event passed JSON Schema validation.
[PASS] No successful graph state was committed.
7Kết quả¶
Smoke run xử lý 3 file và tạo output đủ để kiểm chứng parser lifecycle: 4.699 node events, 5.453 edge events, 3 metadata events và 0 parser errors trong lượt hợp lệ. Lần chạy lại với cùng state skip 3 file không đổi và không tạo node/edge/metadata mới.
| Hạng mục | Kết quả |
|---|---|
| Files processed | 3 |
| Node events | 4.699 |
| Edge events | 5.453 |
| Metadata events | 3 |
| Parser errors trong valid run | 0 |
| Unchanged files skipped | 3 |
Các kiểm tra schema, determinism, incremental skip và syntax-error behavior đều pass. Điều này cho thấy Parser Service có thể tạo event stream ổn định để Kafka tiếp nhận ở Task 3.
8Lệnh xác minh end-to-end¶
Khi cần publish thật, chạy full eligible scope. Khi cần minh họa nhanh, dùng smoke scope giới hạn.
uv run lab04 parse-repository --scope final --no-dry-run
uv run lab04 parse-repository --scope smoke --limit 3 --no-dry-run9Nhận xét¶
Parser xử lý từng file độc lập nên phù hợp với repository lớn và không cần giữ toàn bộ source tree trong memory.
Stable IDs, content hash và SQLite state tạo nền tảng cho replay-safe ingestion ở các downstream systems. Nếu cùng nội dung được xử lý lại, parser có thể skip hoặc phát lại cùng định danh logic thay vì tạo entity mới.
Việc phát PARSER_ERROR riêng giúp pipeline phân biệt lỗi source code với lỗi hạ tầng Kafka/Neo4j, đồng thời tránh commit state thành công cho file parse lỗi.