chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:42:18 +08:00
commit 05f60106aa
288 changed files with 76871 additions and 0 deletions
View File
+47
View File
@@ -0,0 +1,47 @@
package com.example.kafka;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.KafkaHandler;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.KafkaOperations;
import org.springframework.stereotype.Service;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import reactor.kafka.receiver.KafkaReceiver;
// ── Annotation-based consumer ─────────────────────────────────────────────
@Service
class OrderEventConsumer {
@KafkaListener(topics = "order-events")
public void onOrder(String payload) {}
@KafkaListener(topics = {"order-dlq", "order-retry"})
public void onDlq(String payload) {}
}
// ── Annotation-based producer (KafkaTemplate field) ───────────────────────
@Service
@RequiredArgsConstructor
class NotificationProducer {
private final KafkaTemplate<String, String> kafkaTemplate;
// static field — should NOT produce edge
private static final String TOPIC = "notifications";
}
// ── Reactive consumer (KafkaReceiver field) ───────────────────────────────
@Service
@RequiredArgsConstructor
class ReactiveOrderConsumer {
private final KafkaReceiver<String, OrderEvent> kafkaReceiver;
private final KafkaOperations<String, String> kafkaOps;
}
// ── plain class with no Kafka ─────────────────────────────────────────────
class OrderEvent {
private String id;
}
+3
View File
@@ -0,0 +1,3 @@
export function MarkdownMsg() {
return <div />;
}
+48
View File
@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
namespace SampleApp
{
public interface IRepository
{
User FindById(int id);
void Save(User user);
}
public class User
{
public int Id { get; set; }
public string Name { get; set; }
}
public class InMemoryRepo : IRepository
{
private Dictionary<int, User> _users = new();
public User FindById(int id)
{
return _users.ContainsKey(id) ? _users[id] : null;
}
public void Save(User user)
{
_users[user.Id] = user;
Console.WriteLine($"Saved user {user.Id}");
}
}
public class UserService
{
private IRepository _repo;
public UserService(IRepository repo)
{
_repo = repo;
}
public User GetUser(int id)
{
return _repo.FindById(id);
}
}
}
+66
View File
@@ -0,0 +1,66 @@
package com.example.auth;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public interface UserRepository {
Optional<User> findById(int id);
void save(User user);
}
class User {
private int id;
private String name;
private String email;
public User(int id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public int getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
}
class InMemoryRepo implements UserRepository {
private Map<Integer, User> users = new HashMap<>();
@Override
public Optional<User> findById(int id) {
return Optional.ofNullable(users.get(id));
}
@Override
public void save(User user) {
users.put(user.getId(), user);
System.out.println("Saved user " + user.getId());
}
}
class UserService {
private final UserRepository repo;
public UserService(UserRepository repo) {
this.repo = repo;
}
public User createUser(String name, String email) {
User user = new User(1, name, email);
repo.save(user);
return user;
}
public Optional<User> getUser(int id) {
return repo.findById(id);
}
}
class CachedRepo extends InMemoryRepo {
@Override
public void save(User user) {
super.save(user);
}
}
+78
View File
@@ -0,0 +1,78 @@
package com.example.shop;
import org.springframework.stereotype.Service;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.RequiredArgsConstructor;
// Plain interface — not a Spring bean
public interface OrderRepository {
void save(Order order);
Order findById(Long id);
}
// @Repository stereotype — Spring-managed bean
@Repository
class JpaOrderRepository implements OrderRepository {
@Override
public void save(Order order) {}
@Override
public Order findById(Long id) { return null; }
}
// @Service with @Autowired field injection
@Service
class NotificationService {
@Autowired
private OrderRepository orderRepository;
public void notify(Long orderId) {
Order o = orderRepository.findById(orderId);
}
}
// @Service with Lombok @RequiredArgsConstructor (constructor injection via final fields)
@Service
@RequiredArgsConstructor
class OrderService {
private final OrderRepository orderRepository;
private final NotificationService notificationService;
private static final String TAG = "OrderService"; // static final — NOT injected
public void placeOrder(Order order) {
orderRepository.save(order);
notificationService.notify(order.getId());
}
}
// @Component with explicit @Autowired constructor
@Component
class AuditLogger {
private final OrderRepository orderRepository;
@Autowired
public AuditLogger(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void log(String msg) {}
}
// @Configuration with @Bean factory methods
@Configuration
class AppConfig {
@Bean
public OrderRepository orderRepository() {
return new JpaOrderRepository();
}
}
class Order {
private Long id;
public Long getId() { return id; }
}
+72
View File
@@ -0,0 +1,72 @@
package com.example.temporal;
import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;
import io.temporal.workflow.SignalMethod;
import io.temporal.workflow.QueryMethod;
import io.temporal.activity.ActivityInterface;
import io.temporal.activity.ActivityMethod;
// ── Interfaces ───────────────────────────────────────────────────────────────
@WorkflowInterface
public interface OrderWorkflow {
@WorkflowMethod
String processOrder(String orderId);
@SignalMethod
void cancelOrder(String reason);
@QueryMethod
String getStatus();
}
@ActivityInterface
public interface PaymentActivity {
@ActivityMethod
boolean chargeCard(String orderId, double amount);
}
@ActivityInterface
public interface ShippingActivity {
@ActivityMethod
String shipOrder(String orderId);
}
// ── Implementations ──────────────────────────────────────────────────────────
// Workflow impl holds activity stubs as fields
class OrderWorkflowImpl implements OrderWorkflow {
// These fields are assigned via Workflow.newActivityStub() at runtime
private PaymentActivity paymentActivity;
private ShippingActivity shippingActivity;
// Static fields should NOT produce TEMPORAL_STUB edges
private static final String TAG = "OrderWorkflowImpl";
@Override
public String processOrder(String orderId) {
boolean paid = paymentActivity.chargeCard(orderId, 100.0);
if (!paid) return "FAILED";
String trackingId = shippingActivity.shipOrder(orderId);
return trackingId;
}
@Override
public void cancelOrder(String reason) {}
@Override
public String getStatus() { return "OK"; }
}
// Activity impls
class PaymentActivityImpl implements PaymentActivity {
@Override
public boolean chargeCard(String orderId, double amount) { return true; }
}
class ShippingActivityImpl implements ShippingActivity {
@Override
public String shipOrder(String orderId) { return "TRACK-001"; }
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { UserRepository, UserService } from '../sample_typescript';
describe('UserService (under __tests__/)', () => {
it('constructs a service', () => {
const service = new UserService();
expect(service).toBeDefined();
});
it('returns undefined for missing user', () => {
const service = new UserService();
const user = service.getUser(999);
expect(user).toBeUndefined();
});
});
+6
View File
@@ -0,0 +1,6 @@
import { cn } from '@/lib/utils';
import { UserService } from './sample_typescript';
export function formatUser(name: string): string {
return cn('user', name);
}
+8
View File
@@ -0,0 +1,8 @@
"""Fixture that imports and calls functions from sample_python."""
from sample_python import create_auth_service
def setup_and_run():
service = create_auth_service()
return service
+156
View File
@@ -0,0 +1,156 @@
{
"summary": "Analyzed 2 changed file(s):\n - 3 changed function(s)/class(es)\n - 2 affected flow(s)\n - 1 test gap(s)\n - Overall risk score: 0.72\n - Untested: rotate_token",
"risk_score": 0.72,
"changed_functions": [
{
"id": 101,
"kind": "Function",
"name": "rotate_token",
"qualified_name": "auth/session.py::rotate_token",
"file_path": "auth/session.py",
"line_start": 42,
"line_end": 78,
"language": "python",
"parent_name": null,
"is_test": false,
"risk_score": 0.72
},
{
"id": 102,
"kind": "Function",
"name": "validate_session",
"qualified_name": "auth/session.py::validate_session",
"file_path": "auth/session.py",
"line_start": 80,
"line_end": 112,
"language": "python",
"parent_name": null,
"is_test": false,
"risk_score": 0.41
},
{
"id": 103,
"kind": "Function",
"name": "format_expiry",
"qualified_name": "auth/display.py::format_expiry",
"file_path": "auth/display.py",
"line_start": 10,
"line_end": 18,
"language": "python",
"parent_name": null,
"is_test": false,
"risk_score": 0.1
}
],
"affected_flows": [
{
"id": 7,
"name": "login_handler -> rotate_token",
"entry_point_id": 90,
"depth": 4,
"node_count": 6,
"file_count": 3,
"criticality": 0.83,
"path": [90, 95, 101, 102, 110, 111],
"steps": [
{
"node_id": 90,
"name": "login_handler",
"kind": "Function",
"file": "auth/routes.py",
"line_start": 12,
"line_end": 40,
"qualified_name": "auth/routes.py::login_handler"
},
{
"node_id": 101,
"name": "rotate_token",
"kind": "Function",
"file": "auth/session.py",
"line_start": 42,
"line_end": 78,
"qualified_name": "auth/session.py::rotate_token"
}
],
"created_at": "2026-06-01T10:00:00"
},
{
"id": 9,
"name": "cli_main -> validate_session",
"entry_point_id": 120,
"depth": 3,
"node_count": 4,
"file_count": 2,
"criticality": 0.55,
"path": [120, 121, 102, 130],
"steps": [
{
"node_id": 120,
"name": "cli_main",
"kind": "Function",
"file": "cli.py",
"line_start": 5,
"line_end": 60,
"qualified_name": "cli.py::cli_main"
}
],
"created_at": "2026-06-01T10:00:00"
}
],
"test_gaps": [
{
"name": "rotate_token",
"qualified_name": "auth/session.py::rotate_token",
"file": "auth/session.py",
"line_start": 42,
"line_end": 78
}
],
"review_priorities": [
{
"id": 101,
"kind": "Function",
"name": "rotate_token",
"qualified_name": "auth/session.py::rotate_token",
"file_path": "auth/session.py",
"line_start": 42,
"line_end": 78,
"language": "python",
"parent_name": null,
"is_test": false,
"risk_score": 0.72
},
{
"id": 102,
"kind": "Function",
"name": "validate_session",
"qualified_name": "auth/session.py::validate_session",
"file_path": "auth/session.py",
"line_start": 80,
"line_end": 112,
"language": "python",
"parent_name": null,
"is_test": false,
"risk_score": 0.41
},
{
"id": 103,
"kind": "Function",
"name": "format_expiry",
"qualified_name": "auth/display.py::format_expiry",
"file_path": "auth/display.py",
"line_start": 10,
"line_end": 18,
"language": "python",
"parent_name": null,
"is_test": false,
"risk_score": 0.1
}
],
"functions_truncated": false,
"context_savings": {
"estimated": true,
"saved_tokens": 12159,
"saved_percent": 94
}
}
+13
View File
@@ -0,0 +1,13 @@
"""Fixture with multiple calls to the same function from one caller."""
async def _internal_request(url: str, data: bytes) -> dict:
return {"url": url}
async def process_document(content: bytes) -> str:
"""Calls _internal_request twice on different lines."""
first = await _internal_request("http://localhost/fast", content)
text = first.get("body", "")
second = await _internal_request("http://localhost/slow", content)
return text or second.get("body", "")
+30
View File
@@ -0,0 +1,30 @@
library(dplyr)
require(ggplot2)
source("utils.R")
add <- function(x, y) {
x + y
}
multiply = function(a, b) {
a * b
}
MyClass <- setRefClass("MyClass",
fields = list(name = "character", age = "numeric"),
methods = list(
greet = function() {
cat(paste("Hello", name))
},
get_age = function() {
return(age)
}
)
)
process_data <- function(data) {
result <- dplyr::filter(data, x > 5)
summary <- dplyr::summarize(result, mean_x = mean(x))
add(1, 2)
summary
}
+25
View File
@@ -0,0 +1,25 @@
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int id;
char name[50];
} User;
User* create_user(int id, const char* name) {
User* user = malloc(sizeof(User));
user->id = id;
snprintf(user->name, 50, "%s", name);
return user;
}
void print_user(User* user) {
printf("User %d: %s\n", user->id, user->name);
}
int main() {
User* u = create_user(1, "Alice");
print_user(u);
free(u);
return 0;
}
+30
View File
@@ -0,0 +1,30 @@
#include <iostream>
#include <string>
#include <vector>
class Animal {
public:
std::string name;
int age;
Animal(std::string n, int a) : name(n), age(a) {}
virtual void speak() { std::cout << name << " speaks" << std::endl; }
};
class Dog : public Animal {
public:
Dog(std::string n, int a) : Animal(n, a) {}
void speak() override { std::cout << name << " barks" << std::endl; }
void fetch() { std::cout << name << " fetches" << std::endl; }
};
void greet(const Animal& animal) {
std::cout << "Hello " << animal.name << std::endl;
}
int main() {
Dog d("Rex", 5);
d.speak();
greet(d);
return 0;
}
+42
View File
@@ -0,0 +1,42 @@
import 'dart:async';
import 'package:flutter/material.dart';
abstract class Animal {
String get name;
void speak();
}
mixin SwimmingMixin {
void swim() => print('swimming');
}
enum PetType { dog, cat, bird }
class Dog extends Animal with SwimmingMixin {
final String name;
final PetType type;
Dog(this.name) : type = PetType.dog;
@override
void speak() {
print('Woof! I am $name');
}
Future<void> fetch(String item) async {
await _run();
print('Fetched $item');
}
void _run() {
print('running');
}
static Dog create(String name) {
return Dog(name);
}
}
Dog createDog(String name) {
return Dog(name);
}
+36
View File
@@ -0,0 +1,36 @@
defmodule Calculator do
@moduledoc """
Simple calculator module.
"""
def add(a, b) do
a + b
end
def subtract(a, b), do: a - b
defp log(msg) do
IO.puts(msg)
:ok
end
def compute(a, b) do
result = add(a, b)
log("result: #{result}")
result
end
end
defmodule MathHelpers do
alias Calculator
import Calculator, only: [add: 2]
require Logger
def double(x) do
Calculator.compute(x, x)
end
def triple(x) do
double(x) + x
end
end
+41
View File
@@ -0,0 +1,41 @@
extends Node
class_name SampleManager
const MAX_SIZE = 10
const OtherScript = preload("res://scripts/other.gd")
signal item_added(item: Item)
@export var speed: float = 2.5
@onready var timer: Timer = $Timer
var items: Array[Item] = []
class Item:
var name: String
var level: int
func promote() -> void:
level += 1
func _ready() -> void:
timer.start()
_load_items()
OtherScript.register(self)
func _load_items() -> void:
for i in range(MAX_SIZE):
var item := Item.new()
items.append(item)
item_added.emit(item)
func get_item(idx: int) -> Item:
return items[idx]
static func helper() -> int:
return 42
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <string>
class Shape {
public:
std::string color;
Shape(std::string c) : color(c) {}
virtual double area() const = 0;
};
class Circle : public Shape {
public:
double radius;
Circle(std::string c, double r) : Shape(c), radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
};
inline double perimeter(const Circle& circle) {
return 2.0 * 3.14159 * circle.radius;
}
+67
View File
@@ -0,0 +1,67 @@
module SampleModule
using LinearAlgebra
using Statistics: mean, std
import Base: show, print
import JSON
export greet, Dog, process
public square, add
@enum Color RED BLUE GREEN
abstract type AbstractAnimal end
struct Dog <: AbstractAnimal
name::String
age::Int
end
mutable struct MutablePoint
x::Float64
y::Float64
end
function greet(name::String)
println("Hello, $name")
end
function Base.show(io::IO, d::Dog)
print(io, "Dog($(d.name))")
end
add(a, b) = a + b
square(x) = x^2
const MY_CONST = 42
macro sayhello(name)
:(println("Hello, ", $name))
end
function outer()
function inner()
return 1
end
x = inner()
result = map(v -> v^2, [1,2,3])
return x
end
function process(data::Vector{Float64}; verbose=false)
if verbose
println("Processing...")
end
normed = data ./ maximum(data)
return sum(normed) / length(normed)
end
include("utils.jl")
@testset "Arithmetic" begin
@test add(1, 2) == 3
@test square(4) == 16
end
end # module
+27
View File
@@ -0,0 +1,27 @@
package com.example
import java.util.UUID
interface UserRepository {
fun findById(id: Int): User?
fun save(user: User)
}
data class User(val id: Int, val name: String, val email: String)
class InMemoryRepo : UserRepository {
private val users = mutableMapOf<Int, User>()
override fun findById(id: Int): User? = users[id]
override fun save(user: User) {
users[user.id] = user
println("Saved user ${user.id}")
}
}
fun createUser(repo: UserRepository, name: String, email: String): User {
val user = User(1, name, email)
repo.save(user)
return user
}
+139
View File
@@ -0,0 +1,139 @@
-- sample.lua - Comprehensive Lua test fixture for tree-sitter parsing
-- Exercises all major constructs: functions, methods, classes, imports, tables
-- Module-level require() imports
local json = require("cjson")
local utils = require("lib.utils")
local log = require("logging").getLogger("sample")
-- Top-level function declaration
function greet(name)
print("Hello, " .. name)
return name
end
-- Local function declaration
local function helper(x, y)
return x + y
end
-- Variable assignment creating a function
local transform = function(data)
return json.encode(data)
end
-- Another variable-assigned function (module-level)
local validate = function(input)
if input == nil then
return false, "input is nil"
end
return true
end
-- Table constructor as a "class" using metatable + __index pattern
local Animal = {}
Animal.__index = Animal
-- Constructor
function Animal.new(name, sound)
local self = setmetatable({}, Animal)
self.name = name
self.sound = sound
return self
end
-- Method defined with colon syntax
function Animal:speak()
log:info(self.name .. " says " .. self.sound)
return self.sound
end
-- Another colon-syntax method
function Animal:rename(new_name)
local old = self.name
self.name = new_name
return old
end
-- Inheritance pattern
local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog
function Dog.new(name)
local self = Animal.new(name, "Woof")
return setmetatable(self, Dog)
end
function Dog:fetch(item)
self:speak()
print(self.name .. " fetches " .. item)
return item
end
-- Nested function calls and method calls
local function process_animals()
local a = Animal.new("Cat", "Meow")
local d = Dog.new("Rex")
-- Method calls (colon syntax)
a:speak()
d:speak()
d:fetch("ball")
-- Dot-syntax method call
local encoded = json.encode({ animals = { a.name, d.name } })
-- Nested calls
print(string.format("Processed %d animals", 2))
utils.log(json.decode(encoded))
return encoded
end
-- Table constructor with mixed fields
local config = {
debug = true,
version = "1.0.0",
max_retries = 3,
handlers = {
on_error = function(err)
log:error(err)
end,
on_success = function(result)
log:info("OK: " .. tostring(result))
end,
},
}
-- Simple "test" function (test_something pattern)
local function test_greet()
local result = greet("World")
assert(result == "World", "greet should return name")
end
local function test_animal_speak()
local a = Animal.new("TestCat", "Mew")
local sound = a:speak()
assert(sound == "Mew", "speak should return sound")
end
local function test_dog_fetch()
local d = Dog.new("TestDog")
local item = d:fetch("stick")
assert(item == "stick", "fetch should return item")
end
-- Return statement (module pattern)
return {
greet = greet,
helper = helper,
transform = transform,
validate = validate,
Animal = Animal,
Dog = Dog,
process_animals = process_animals,
config = config,
test_greet = test_greet,
test_animal_speak = test_animal_speak,
test_dog_fetch = test_dog_fetch,
}
+119
View File
@@ -0,0 +1,119 @@
-- sample.luau - Luau test fixture for tree-sitter parsing
-- Exercises Luau-specific features: type annotations, type aliases, and Lua constructs
-- Module-level require() imports
local HttpService = require(game.ReplicatedStorage.HttpService)
local utils = require("lib.utils")
local log = require("logging").getLogger("sample")
-- Type alias (Luau-specific)
type Vector3 = {
x: number,
y: number,
z: number,
}
type Callback = (input: string) -> string
-- Top-level function with type annotations
function greet(name: string): string
print("Hello, " .. name)
return name
end
-- Local function with type annotations
local function add(a: number, b: number): number
return a + b
end
-- Variable assignment creating a function
local transform = function(data: any): string
return HttpService:JSONEncode(data)
end
-- Table constructor as a "class" using metatable + __index pattern
local Animal = {}
Animal.__index = Animal
-- Constructor with type annotations
function Animal.new(name: string, sound: string): Animal
local self = setmetatable({}, Animal)
self.name = name
self.sound = sound
return self
end
-- Method defined with colon syntax
function Animal:speak(): string
log:info(self.name .. " says " .. self.sound)
return self.sound
end
-- Another colon-syntax method
function Animal:rename(new_name: string): string
local old = self.name
self.name = new_name
return old
end
-- Inheritance pattern
local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog
function Dog.new(name: string): Dog
local self = Animal.new(name, "Woof")
return setmetatable(self, Dog)
end
function Dog:fetch(item: string): string
self:speak()
print(self.name .. " fetches " .. item)
return item
end
-- Nested function calls and method calls
local function process_animals(): string
local a = Animal.new("Cat", "Meow")
local d = Dog.new("Rex")
a:speak()
d:speak()
d:fetch("ball")
local encoded = HttpService:JSONEncode({ animals = { a.name, d.name } })
print(string.format("Processed %d animals", 2))
utils.log(encoded)
return encoded
end
-- Test functions
local function test_greet()
local result = greet("World")
assert(result == "World", "greet should return name")
end
local function test_animal_speak()
local a = Animal.new("TestCat", "Mew")
local sound = a:speak()
assert(sound == "Mew", "speak should return sound")
end
local function test_dog_fetch()
local d = Dog.new("TestDog")
local item = d:fetch("stick")
assert(item == "stick", "fetch should return item")
end
-- Return statement (module pattern)
return {
greet = greet,
add = add,
transform = transform,
Animal = Animal,
Dog = Dog,
process_animals = process_animals,
test_greet = test_greet,
test_animal_speak = test_animal_speak,
test_dog_fetch = test_dog_fetch,
}
+47
View File
@@ -0,0 +1,47 @@
#import <Foundation/Foundation.h>
#import "Logger.h"
@interface Calculator : NSObject
@property(nonatomic) NSInteger result;
- (NSInteger)add:(NSInteger)a to:(NSInteger)b;
- (void)reset;
+ (Calculator *)sharedCalculator;
@end
@implementation Calculator
- (NSInteger)add:(NSInteger)a to:(NSInteger)b {
NSInteger sum = a + b;
self.result = sum;
[self logResult:sum];
return sum;
}
- (void)reset {
self.result = 0;
NSLog(@"Calculator reset");
}
- (void)logResult:(NSInteger)value {
NSLog(@"Result: %ld", (long)value);
}
+ (Calculator *)sharedCalculator {
static Calculator *instance = nil;
if (instance == nil) {
instance = [[Calculator alloc] init];
}
return instance;
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
Calculator *calc = [Calculator sharedCalculator];
NSInteger r = [calc add:3 to:4];
[calc reset];
NSLog(@"Final: %ld", (long)r);
}
return 0;
}
+17
View File
@@ -0,0 +1,17 @@
{
description = "Sample flake fixture for code-review-graph tests";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
in {
packages.default = pkgs.callPackage ./default.nix { };
devShells.default = import ./shell.nix { inherit pkgs; };
});
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Models;
use Exception;
interface Repository {
public function findById(int $id): ?User;
public function save(User $user): void;
}
class User {
public int $id;
public string $name;
public function __construct(int $id, string $name) {
$this->id = $id;
$this->name = $name;
}
public function toString(): string {
return "User({$this->id}, {$this->name})";
}
}
class InMemoryRepo implements Repository {
private array $users = [];
public function findById(int $id): ?User {
return $this->users[$id] ?? null;
}
public function save(User $user): void {
$this->users[$user->id] = $user;
echo "Saved " . $user->toString() . "\n";
}
}
function createUser(Repository $repo, string $name): User {
$user = new User(count($repo->users ?? []) + 1, $name);
$repo->save($user);
return $user;
}
function sqlQuery(string $query): array {
return [];
}
function xl(string $value): string {
return $value;
}
function text(string $value): string {
return $value;
}
class SearchService {
public function search(string $term): array {
return [];
}
}
class QueryUtils {
public static function fetchRecords(): array {
return [];
}
}
class EncounterService {
public static function create(array $payload): bool {
return true;
}
}
class ExtendedRepo extends InMemoryRepo {
public function __construct() {
parent::__construct();
}
public static function factory(): self {
return new self();
}
private function execute(): void {
// no-op helper used for call extraction coverage
}
public function runQueries(?SearchService $service): void {
sqlQuery("SELECT 1");
xl("hello");
text("world");
$this->execute();
$service?->search("blood pressure");
QueryUtils::fetchRecords();
EncounterService::create([]);
parent::__construct();
self::factory();
\dirname("/tmp");
}
}
+33
View File
@@ -0,0 +1,33 @@
use strict;
use warnings;
use File::Basename;
package Animal;
sub new {
my ($class, %args) = @_;
return bless \%args, $class;
}
sub speak {
my ($self) = @_;
return "...";
}
package Dog;
sub new {
my ($class, %args) = @_;
my $self = Animal::new($class, %args);
return $self;
}
sub fetch {
my ($self, $item) = @_;
return "Fetched $item";
}
sub bark {
my ($self) = @_;
print $self->speak() . "\n";
}
+38
View File
@@ -0,0 +1,38 @@
require 'json'
module Auth
class User
attr_accessor :id, :name, :email
def initialize(id, name, email)
@id = id
@name = name
@email = email
end
def to_s
"User(#{@id}, #{@name})"
end
end
class UserRepository
def initialize
@users = {}
end
def find_by_id(id)
@users[id]
end
def save(user)
@users[user.id] = user
puts "Saved #{user}"
end
def create_user(name, email)
user = User.new(@users.size + 1, name, email)
save(user)
user
end
end
end
+79
View File
@@ -0,0 +1,79 @@
// sample.res - Comprehensive ReScript test fixture
// Exercises modules, nested modules, let/rec, externals, types, opens,
// decorators, function calls, and test-style bindings.
open Belt
include Js.Promise
open Belt
// Module alias (re-export)
module IntMap = Belt.Map.Int
// JS-binding module: only types + externals, should be tagged js_binding
module TextEncoder = {
type encoder
@new external newTextEncoder: unit => encoder = "TextEncoder"
@send external encode: (encoder, string) => array<int> = "encode"
}
// Top-level type definition
type status = Active | Inactive | Pending
// Top-level type alias with polymorphic parameter
type result<'a> = Ok('a) | Err(string)
// Top-level let binding
let defaultTimeout = 5000
// let rec + and chain
let rec fact = n => n <= 1 ? 1 : n * fact(n - 1)
and helper = x => fact(x) + 1
// External binding with decorator
@module("fs") external readFile: string => string = "readFileSync"
@val external consoleLog: string => unit = "console.log"
// Nested module
module User = {
type t = {name: string, age: int, status: status}
let make = (~name, ~age) => {name, age, status: Active}
let greet = (user: t) => consoleLog("Hello " ++ user.name)
// Nested sub-module
module Validator = {
let isAdult = (user: t) => user.age >= 18
let hasName = (user: t) => user.name != ""
}
}
// Another top-level module using the previous one
module App = {
let start = () => {
let u = User.make(~name="Ada", ~age=36)
User.greet(u)
let valid = User.Validator.isAdult(u)
consoleLog(valid ? "ok" : "nope")
}
}
// Top-level function calling into modules
let main = () => {
App.start()
let n = fact(5)
consoleLog(Belt.Int.toString(n))
}
// JSX rendering — component references across modules
let render = () =>
<Layout>
<User.Badge name="Ada" />
<AnalyticsFilterUi.Filter filter="amount" />
</Layout>
// Test-style function (rescript-test convention)
let test_fact_base = () => {
let r = fact(1)
assert(r == 1)
}
+27
View File
@@ -0,0 +1,27 @@
/* sample.resi - ReScript interface file fixture.
Only signatures — no expression bodies. */
type status = Active | Inactive | Pending
type result<'a> = Ok('a) | Err(string)
let defaultTimeout: int
let fact: int => int
module User: {
type t
let make: (~name: string, ~age: int) => t
let greet: t => unit
module Validator: {
let isAdult: t => bool
let hasName: t => bool
}
}
module App: {
let start: unit => unit
}
external readFile: string => string = "readFileSync"
+37
View File
@@ -0,0 +1,37 @@
package com.example.auth
import scala.collection.mutable
import scala.collection.mutable.{HashMap, ListBuffer}
import scala.util.Try
import scala.concurrent._
trait Repository[T]:
def findById(id: Int): Option[T]
def save(entity: T): Unit
case class User(id: Int, name: String, email: String)
class InMemoryRepo extends Repository[User] with Serializable:
private val users = mutable.HashMap[Int, User]()
override def findById(id: Int): Option[User] =
users.get(id)
override def save(user: User): Unit =
users.put(user.id, user)
println(s"Saved user ${user.id}")
class UserService(repo: Repository[User]):
def createUser(name: String, email: String): User =
val user = User(1, name, email)
repo.save(user)
user
def getUser(id: Int): Option[User] =
repo.findById(id)
object UserService:
def apply(repo: Repository[User]): UserService = new UserService(repo)
enum Color:
case Red, Green, Blue
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# Sample shell script exercising the bash parser.
set -euo pipefail
source ./sample_lib.sh
. ./sample_config.sh
readonly DATA_DIR="/tmp/crg-example"
log_info() {
local msg="$1"
echo "[INFO] $msg"
}
log_error() {
local msg="$1"
echo "[ERROR] $msg" >&2
}
ensure_dir() {
local dir="$1"
if [ ! -d "$dir" ]; then
mkdir -p "$dir"
log_info "created $dir"
fi
}
cleanup() {
rm -rf "$DATA_DIR"
log_info "cleaned up $DATA_DIR"
}
main() {
log_info "starting"
ensure_dir "$DATA_DIR"
# Simulate some work
echo "processing" > "$DATA_DIR/status"
cleanup
log_info "done"
}
main "$@"
+218
View File
@@ -0,0 +1,218 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import {IERC20, IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
// ─── Protocol constants ─────────────────────────────────────────────────────
uint256 constant MAX_SUPPLY = 1_000_000_000 ether;
address constant ZERO_ADDRESS = address(0);
// ─── Types ──────────────────────────────────────────────────────────────────
/// @notice Staker position tracked per epoch.
struct StakerPosition {
address wallet;
uint256 stakedAmount;
uint256 rewardDebt;
uint64 epochJoined;
bool isActive;
}
/// @notice Pool lifecycle.
enum PoolStatus {
Active,
Paused,
Deprecated,
EmergencyShutdown
}
/// @notice 18-decimal fixed-point price.
type Price is uint256;
/// @notice Position receipt NFT identifier.
type PositionId is uint128;
// ─── Errors ─────────────────────────────────────────────────────────────────
error InsufficientStake(uint256 requested, uint256 available);
error PoolNotActive();
// ─── Events ─────────────────────────────────────────────────────────────────
event Staked(address indexed user, uint256 amount);
event Unstaked(address indexed user, uint256 amount);
// ─── Helpers ────────────────────────────────────────────────────────────────
/// @notice 30 bp protocol fee.
function protocolFee(uint256 amount) pure returns (uint256) {
return (amount * 30) / 10_000;
}
// ─── Interface ──────────────────────────────────────────────────────────────
interface IStakingPool {
function stake(uint256 amount) external;
function unstake(uint256 amount) external returns (uint256);
function stakedBalance(address user) external view returns (uint256);
}
// ─── Library ────────────────────────────────────────────────────────────────
/// @notice Fixed-point math for reward accumulator precision.
library RewardMath {
uint256 internal constant PRECISION = 1e18;
function mulPrecise(uint256 a, uint256 b) internal pure returns (uint256) {
return (a * b) / PRECISION;
}
function divPrecise(uint256 a, uint256 b) internal pure returns (uint256) {
require(b > 0, "RewardMath: division by zero");
return (a * PRECISION) / b;
}
}
// ─── Core pool ──────────────────────────────────────────────────────────────
/// @title StakingVault
/// @notice Liquid staking pool. Deposit the underlying ERC-20, receive
/// share tokens 1 : 1, accrue rewards over time.
contract StakingVault is ERC20, Ownable, IStakingPool {
using RewardMath for uint256;
// ── Storage ────────────────────────────────────────────────────────
mapping(address => uint256) public stakes;
uint256 public totalStaked;
address public guardian;
PoolStatus public status;
uint256 constant MIN_STAKE = 0.01 ether;
uint256 immutable launchTime;
Price public assetPrice;
uint256 public accRewardPerShare;
// ── Events ─────────────────────────────────────────────────────────
event RewardAccrued(uint256 indexed epoch, uint256 amount);
event EmergencyExit(address indexed user, uint256 amount);
// ── Modifiers ──────────────────────────────────────────────────────
modifier nonZero(uint256 amount) {
require(amount > 0, "StakingVault: zero amount");
_;
}
modifier whenPoolActive() {
require(status == PoolStatus.Active, "StakingVault: pool not active");
_;
}
// ── Constructor ────────────────────────────────────────────────────
constructor(
string memory name,
string memory symbol
) ERC20(name, symbol) Ownable(msg.sender) {
guardian = msg.sender;
launchTime = block.timestamp;
status = PoolStatus.Active;
}
// ── Core operations ────────────────────────────────────────────────
/// @inheritdoc IStakingPool
function stake(uint256 amount)
external
override
nonZero(amount)
whenPoolActive
{
uint256 fee = protocolFee(amount);
uint256 net = amount - fee;
stakes[msg.sender] += net;
totalStaked += net;
_mint(msg.sender, net);
emit Staked(msg.sender, net);
}
/// @inheritdoc IStakingPool
function unstake(uint256 amount)
external
override
nonZero(amount)
returns (uint256)
{
uint256 staked = stakes[msg.sender];
if (staked < amount) {
revert InsufficientStake(amount, staked);
}
stakes[msg.sender] = staked - amount;
totalStaked -= amount;
_burn(msg.sender, amount);
emit Unstaked(msg.sender, amount);
return amount;
}
/// @inheritdoc IStakingPool
function stakedBalance(address user) external view returns (uint256) {
return stakes[user];
}
// ── Emergency ──────────────────────────────────────────────────────
function emergencyWithdraw() external nonZero(stakes[msg.sender]) {
uint256 amount = stakes[msg.sender];
stakes[msg.sender] = 0;
totalStaked -= amount;
_burn(msg.sender, amount);
emit EmergencyExit(msg.sender, amount);
}
// ── ETH handling (native staking variant) ──────────────────────────
receive() external payable {}
fallback() external payable {}
}
// ─── Boosted pool ───────────────────────────────────────────────────────────
/// @title BoostedPool
/// @notice Wraps StakingVault with an additional reward layer.
/// Depositors earn base yield from the vault plus bonus
/// rewards funded by governance.
contract BoostedPool is StakingVault {
uint256 public bonusRate;
event BonusClaimed(address indexed user, uint256 reward);
constructor(
string memory name,
string memory symbol,
uint256 _bonusRate
) StakingVault(name, symbol) {
bonusRate = _bonusRate;
}
function pendingBonus(address user) public view returns (uint256) {
if (totalStaked == 0) return 0;
return stakes[user].mulPrecise(bonusRate);
}
function claimBonus() external {
uint256 reward = pendingBonus(msg.sender);
require(reward > 0, "BoostedPool: nothing to claim");
_mint(msg.sender, reward);
emit BonusClaimed(msg.sender, reward);
}
}
+37
View File
@@ -0,0 +1,37 @@
-- Sample SQL fixture for code-review-graph parser tests
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
total NUMERIC(10, 2),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE VIEW active_orders AS
SELECT o.id, u.name, o.total
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.total > 0;
CREATE FUNCTION get_user_total(p_user_id INTEGER)
RETURNS NUMERIC AS $$
SELECT SUM(total)
FROM orders
WHERE user_id = p_user_id;
$$ LANGUAGE sql;
CREATE OR REPLACE PROCEDURE archive_old_orders(cutoff_date DATE)
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO orders_archive
SELECT * FROM orders WHERE created_at < cutoff_date;
DELETE FROM orders WHERE created_at < cutoff_date;
END;
$$;
+77
View File
@@ -0,0 +1,77 @@
// sample.sv - SystemVerilog fixture for parser tests
`timescale 1ns / 1ps
// File-level package import
import utils_pkg::*;
// Interface declaration
interface BusIf #(parameter int WIDTH = 8);
logic [WIDTH-1:0] data;
logic valid;
logic ready;
modport master(output data, valid, input ready);
modport slave(input data, valid, output ready);
endinterface
// Submodule to be instantiated by FIFOController
module Adder #(parameter int WIDTH = 8) (input logic [WIDTH-1:0] a, b, output logic [WIDTH-1:0] sum);
assign sum = a + b;
endmodule
// Main module with tasks, functions, always blocks, and module instantiation
// Parameters on one line to avoid grammar parse errors
module FIFOController #(parameter int DEPTH = 16, parameter int WIDTH = 8) (
input logic clk,
input logic rst_n,
input logic [WIDTH-1:0] data_in,
input logic wr_en,
input logic rd_en,
output logic [WIDTH-1:0] data_out,
output logic full,
output logic empty
);
// Intra-module package import
import arith_pkg::counter_t;
logic [WIDTH-1:0] mem [0:DEPTH-1];
logic [$clog2(DEPTH):0] wr_ptr, rd_ptr, count;
// Module instantiation - creates CALLS edge from FIFOController to Adder
Adder #(.WIDTH(WIDTH)) ptr_adder (.a(wr_ptr[WIDTH-1:0]), .b(rd_ptr[WIDTH-1:0]), .sum());
// Task declaration
task automatic do_write(input logic [WIDTH-1:0] din);
mem[wr_ptr] <= din;
wr_ptr <= wr_ptr + 1;
count <= count + 1;
endtask
// Function declaration
function automatic logic is_full();
return (count >= DEPTH);
endfunction
// Always block (sequential logic) - flattened to avoid nested begin/end
// grammar limitation: if(x) begin..end inside else begin..end causes parse errors
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wr_ptr <= 0;
rd_ptr <= 0;
count <= 0;
end
if (rst_n && wr_en && !full) do_write(data_in);
if (rst_n && rd_en && !empty) begin
data_out <= mem[rd_ptr];
rd_ptr <= rd_ptr + 1;
count <= count - 1;
end
end
// Always block (combinational logic)
always_comb begin
full = is_full();
empty = (count == 0);
end
endmodule
+60
View File
@@ -0,0 +1,60 @@
import Foundation
protocol UserRepository {
func findById(_ id: Int) -> User?
func save(_ user: User)
}
struct User {
let id: Int
let name: String
let email: String
}
class InMemoryRepo: UserRepository {
private var users: [Int: User] = [:]
func findById(_ id: Int) -> User? {
return users[id]
}
func save(_ user: User) {
users[user.id] = user
print("Saved user \(user.id)")
}
}
enum Direction: String {
case north
case south
case east
case west
}
actor DataStore {
private var cache: [String: User] = [:]
func get(_ key: String) -> User? {
return cache[key]
}
func set(_ key: String, user: User) {
cache[key] = user
}
}
extension InMemoryRepo: CustomStringConvertible {
var description: String {
return "InMemoryRepo with \(users.count) users"
}
func clear() {
users.removeAll()
}
}
func createUser(repo: UserRepository, name: String, email: String) -> User {
let user = User(id: 1, name: name, email: email)
repo.save(user)
return user
}
+32
View File
@@ -0,0 +1,32 @@
#include "EXTERN.h"
#include "perl.h"
#include "XSUB.h"
#include <string.h>
typedef struct {
int x;
int y;
} Point;
static int
_add(int a, int b) {
return a + b;
}
static double
compute_distance(int x1, int y1, int x2, int y2) {
int dx = x2 - x1;
int dy = y2 - y1;
return _add(dx * dx, dy * dy);
}
MODULE = MyModule PACKAGE = MyModule
int
add(a, b)
int a
int b
CODE:
RETVAL = _add(a, b);
OUTPUT:
RETVAL
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, test, expect, beforeEach } from 'bun:test';
import { UserRepository, UserService } from './sample_typescript';
describe('UserService (bun)', () => {
let repo: UserRepository;
beforeEach(() => {
repo = new UserRepository();
});
it('constructs a service with a repository', () => {
const service = new UserService();
expect(service).toBeDefined();
});
it('finds a user by id', () => {
const service = new UserService();
const user = service.getUser(123);
expect(user).toBeUndefined();
});
test('creates a user via the service', () => {
const service = new UserService();
const created = service.createUser('alice', 'alice@example.com');
expect(created.name).toBe('alice');
});
});
+35
View File
@@ -0,0 +1,35 @@
"""Fixture for issue #363: function references in callback positions.
Each `*_callback` function is passed as a bare-identifier argument to
another call. They are never invoked with parens, so without REFERENCES
edge tracking they would be flagged as dead code.
"""
from concurrent.futures import ThreadPoolExecutor
def executor_callback():
return "submitted"
def filter_callback(item):
return item > 0
def map_callback(item):
return item * 2
def trigger_executor():
with ThreadPoolExecutor() as executor:
future = executor.submit(executor_callback)
return future
def trigger_filter():
items = [1, -2, 3, -4]
return list(filter(filter_callback, items))
def trigger_map():
items = [1, 2, 3]
return list(map(map_callback, items))
+37
View File
@@ -0,0 +1,37 @@
# Databricks notebook source
import os
from pathlib import Path
def load_config():
return {"env": os.getenv("ENV", "dev")}
# COMMAND ----------
# MAGIC %sql
# MAGIC SELECT * FROM bronze.events
# MAGIC JOIN silver.users ON events.user_id = users.id
# COMMAND ----------
# MAGIC %r
# MAGIC summarize_data <- function(df) {
# MAGIC summary(df)
# MAGIC }
# COMMAND ----------
# MAGIC %md
# MAGIC ## Analysis Notes
# MAGIC This section documents the analysis.
# COMMAND ----------
def process_events(config):
path = Path(config["env"])
return load_config()
# COMMAND ----------
# MAGIC %sql
# MAGIC CREATE TABLE gold.summary AS SELECT * FROM silver.processed
+59
View File
@@ -0,0 +1,59 @@
{
"cells": [
{
"cell_type": "markdown",
"source": ["# Databricks Notebook"],
"metadata": {}
},
{
"cell_type": "code",
"source": ["%python\n", "def transform_data(df):\n", " return df.dropna()\n"],
"metadata": {},
"outputs": []
},
{
"cell_type": "code",
"source": ["%sql\n", "SELECT * FROM catalog.schema.raw_data\n", "JOIN catalog.schema.lookup ON raw_data.id = lookup.id\n"],
"metadata": {},
"outputs": []
},
{
"cell_type": "code",
"source": ["%r\n", "clean_data <- function(x) {\n", " na.omit(x)\n", "}\n"],
"metadata": {},
"outputs": []
},
{
"cell_type": "code",
"source": ["%scala\n", "val x = 1\n"],
"metadata": {},
"outputs": []
},
{
"cell_type": "code",
"source": ["%md\n", "## Results section\n"],
"metadata": {},
"outputs": []
},
{
"cell_type": "code",
"source": ["def process_results(data):\n", " result = transform_data(data)\n", " return result\n"],
"metadata": {},
"outputs": []
},
{
"cell_type": "code",
"source": ["%sql\n", "CREATE TABLE catalog.schema.output AS SELECT * FROM catalog.schema.raw_data\n"],
"metadata": {},
"outputs": []
}
],
"metadata": {
"kernelspec": {
"language": "python",
"display_name": "Python 3"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+48
View File
@@ -0,0 +1,48 @@
package auth
import (
"errors"
"fmt"
)
type User struct {
ID int
Name string
Email string
}
type UserRepository interface {
FindByID(id int) (*User, error)
Save(user *User) error
}
type InMemoryRepo struct {
users map[int]*User
}
func NewInMemoryRepo() *InMemoryRepo {
return &InMemoryRepo{users: make(map[int]*User)}
}
func (r *InMemoryRepo) FindByID(id int) (*User, error) {
user, ok := r.users[id]
if !ok {
return nil, errors.New("user not found")
}
return user, nil
}
func (r *InMemoryRepo) Save(user *User) error {
r.users[user.ID] = user
fmt.Printf("Saved user %d\n", user.ID)
return nil
}
func CreateUser(repo UserRepository, name string, email string) (*User, error) {
user := &User{ID: 1, Name: name, Email: email}
err := repo.Save(user)
if err != nil {
return nil, err
}
return user, nil
}
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# Helper library sourced by sample.sh — used to verify `source` is
# resolved to a real file by _resolve_module_to_file.
lib_helper() {
echo "helper called"
}
+47
View File
@@ -0,0 +1,47 @@
# Fixture for testing REFERENCES edge extraction in Python map dispatch patterns.
def handle_create(data):
print("create", data)
def handle_update(data):
print("update", data)
def handle_delete(data):
print("delete", data)
def validate_input(data):
return data is not None
def process_data(data):
return data
def format_output(data):
return str(data)
# Pattern 1: Dict with function values
handlers = {
"create": handle_create,
"update": handle_update,
"delete": handle_delete,
}
# Pattern 2: List of function references (pipeline)
pipeline = [validate_input, process_data, format_output]
# Pattern 3: Assignment to dict key
dynamic_handlers = {}
dynamic_handlers["format"] = format_output
def dispatch(action):
handler = handlers.get(action)
if handler:
handler({})
+55
View File
@@ -0,0 +1,55 @@
// Fixture for testing REFERENCES edge extraction in map dispatch patterns.
function handleCreate(data: any): void {
console.log("create", data);
}
function handleUpdate(data: any): void {
console.log("update", data);
}
function handleDelete(data: any): void {
console.log("delete", data);
}
function validateInput(data: any): boolean {
return data != null;
}
function processData(data: any): any {
return data;
}
function formatOutput(data: any): string {
return JSON.stringify(data);
}
// Pattern 1: Object literal with function values (Record<string, Handler>)
const handlers: Record<string, (data: any) => void> = {
create: handleCreate,
update: handleUpdate,
delete: handleDelete,
};
// Pattern 2: Shorthand property references
const shorthandMap = { validateInput, processData };
// Pattern 3: Property assignment to map
const dynamicHandlers: Record<string, Function> = {};
dynamicHandlers['format'] = formatOutput;
// Pattern 4: Array of function references (pipeline)
const pipeline = [validateInput, processData, formatOutput];
// Pattern 5: Function passed as callback argument
function register(fn: Function): void {
// registration logic
}
function dispatch(action: string): void {
const handler = handlers[action];
if (handler) {
register(handleCreate);
handler({});
}
}
+16
View File
@@ -0,0 +1,16 @@
// Mocha TDD interface: enabled via `mocha --ui tdd`.
// `suite` is the describe-equivalent and `test` is the it-equivalent.
import { UserRepository, UserService } from './sample_typescript';
suite('UserService (mocha TDD)', () => {
test('constructs a service', () => {
const service = new UserService();
if (!service) throw new Error('expected service');
});
test('returns undefined for unknown id', () => {
const service = new UserService();
const user = service.getUser(404);
if (user !== undefined) throw new Error('expected undefined');
});
});
+12
View File
@@ -0,0 +1,12 @@
{ lib, pkgs, ... }:
let
helper = import ./foo.nix { inherit lib; };
in {
environment.systemPackages = [ pkgs.hello ];
services.myservice = {
enable = true;
greeting = helper.greeting;
};
}
+91
View File
@@ -0,0 +1,91 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "7fb27b941602401d91542211134fc71a",
"metadata": {},
"source": [
"# Sample Notebook\n",
"This is a markdown cell."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "acae54e37e7d407bbb7b55eff062a284",
"metadata": {},
"outputs": [],
"source": [
"%pip install pandas\n",
"!ls -la\n",
"import os\n",
"from pathlib import Path\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
"metadata": {},
"outputs": [],
"source": [
"import math\n",
"\n",
"def add(x, y):\n",
" return x + y\n",
"\n",
"def multiply(a, b):\n",
" return a * b\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8dd0d8092fe74a7c96281538738b07e2",
"metadata": {},
"outputs": [],
"source": [
"class DataProcessor:\n",
" def __init__(self, name):\n",
" self.name = name\n",
"\n",
" def process(self, data):\n",
" result = add(data, 1)\n",
" return multiply(result, 2)\n"
]
},
{
"cell_type": "raw",
"id": "72eea5119410473aa328ad9291626812",
"metadata": {},
"source": [
"This raw cell should be skipped."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8edb47106e1a46a883d545849b8ab81b",
"metadata": {},
"outputs": [],
"source": [
"processor = DataProcessor('test')\n",
"output = processor.process(5)\n",
"print(output)\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+51
View File
@@ -0,0 +1,51 @@
"""Sample Python file for testing the parser."""
import os
from pathlib import Path # noqa: F401 — used by parser tests
class BaseService:
"""A base service class."""
def __init__(self, name: str):
self.name = name
def start(self) -> None:
print(f"Starting {self.name}")
class AuthService(BaseService):
"""Authentication service."""
def __init__(self, name: str, secret: str):
super().__init__(name)
self.secret = secret
def authenticate(self, token: str) -> bool:
return self._validate_token(token)
def _validate_token(self, token: str) -> bool:
return token == self.secret
def create_auth_service() -> AuthService:
secret = os.environ.get("SECRET", "default")
return AuthService("auth", secret)
def process_request(service: AuthService, token: str) -> dict:
if service.authenticate(token):
return {"status": "ok"}
return {"status": "denied"}
def _log_action(func):
"""Simple decorator."""
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@_log_action
def guarded_process(service: AuthService, token: str) -> dict:
return process_request(service, token)
+69
View File
@@ -0,0 +1,69 @@
use std::collections::HashMap;
pub trait Repository {
fn find_by_id(&self, id: u64) -> Option<&User>;
fn save(&mut self, user: User);
}
#[derive(Debug, Clone)]
pub struct User {
pub id: u64,
pub name: String,
pub email: String,
}
pub struct InMemoryRepo {
users: HashMap<u64, User>,
}
impl InMemoryRepo {
pub fn new() -> Self {
InMemoryRepo {
users: HashMap::new(),
}
}
}
impl Repository for InMemoryRepo {
fn find_by_id(&self, id: u64) -> Option<&User> {
self.users.get(&id)
}
fn save(&mut self, user: User) {
println!("Saving user {}", user.id);
self.users.insert(user.id, user);
}
}
pub fn create_user(repo: &mut impl Repository, name: &str, email: &str) -> User {
let user = User {
id: 1,
name: name.to_string(),
email: email.to_string(),
};
repo.save(user.clone());
user
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_repo_is_empty() {
let repo = InMemoryRepo::new();
assert!(repo.find_by_id(1).is_none());
}
#[test]
fn create_user_saves_to_repo() {
let mut repo = InMemoryRepo::new();
let user = create_user(&mut repo, "alice", "a@b.c");
assert_eq!(user.name, "alice");
}
#[tokio::test]
async fn async_test_is_detected() {
assert!(true);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Request, Response } from 'express';
interface UserData {
id: number;
name: string;
email: string;
}
class UserRepository {
private users: Map<number, UserData> = new Map();
findById(id: number): UserData | undefined {
return this.users.get(id);
}
save(user: UserData): void {
this.users.set(user.id, user);
}
}
class UserService extends UserRepository {
getUser(id: number): UserData | undefined {
return this.findById(id);
}
createUser(name: string, email: string): UserData {
const user: UserData = { id: Date.now(), name, email };
this.save(user);
return user;
}
}
export function handleGetUser(req: Request, res: Response): void {
const service = new UserService();
const user = service.getUser(Number(req.params.id));
if (user) {
res.json(user);
} else {
res.status(404).json({ error: 'Not found' });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { UserRepository, UserService } from './sample_typescript';
describe('UserService', () => {
it('should create a user', () => {
const repo = new UserRepository();
const service = new UserService(repo);
});
it('should find a user by id', () => {
const repo = new UserRepository();
const service = new UserService(repo);
const user = service.findById('123');
});
test('alternative test syntax', () => {
const repo = new UserRepository();
});
});
+35
View File
@@ -0,0 +1,35 @@
<template>
<div class="app">
<h1>{{ title }}</h1>
<UserList :users="users" @select="onSelectUser" />
<button @click="increment">Count: {{ count }}</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import UserList from './UserList.vue'
interface User {
id: number
name: string
}
const count = ref(0)
const title = ref('My App')
const users = ref<User[]>([])
function increment() {
count.value++
}
function onSelectUser(user: User) {
console.log(user.name)
}
const doubled = computed(() => count.value * 2)
function fetchUsers() {
return fetch('/api/users')
}
</script>
+3
View File
@@ -0,0 +1,3 @@
export function cn(...args: string[]): string {
return args.join(' ');
}
+9
View File
@@ -0,0 +1,9 @@
library(testthat)
test_that("addition works", {
expect_equal(add(1, 2), 3)
})
test_add <- function() {
stopifnot(add(1, 2) == 3)
}
+19
View File
@@ -0,0 +1,19 @@
"""Tests for sample_python.py - used to verify TESTED_BY edge detection."""
from tests.fixtures.sample_python import AuthService, process_request
def test_authenticate_valid():
service = AuthService("test", "secret123")
assert service.authenticate("secret123") is True
def test_authenticate_invalid():
service = AuthService("test", "secret123")
assert service.authenticate("wrong") is False
def test_process_request_ok():
service = AuthService("test", "secret123")
result = process_request(service, "secret123")
assert result["status"] == "ok"
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@utils/*": ["src/lib/utils/*"]
}
}
}
+286
View File
@@ -0,0 +1,286 @@
"""Tests for scripts/render_pr_comment.py (GitHub Action comment renderer)."""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "scripts" / "render_pr_comment.py"
FIXTURE = REPO_ROOT / "tests" / "fixtures" / "detect_changes_sample.json"
_spec = importlib.util.spec_from_file_location("render_pr_comment", SCRIPT)
assert _spec is not None and _spec.loader is not None
render = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(render)
@pytest.fixture()
def report() -> dict:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# risk_level
# ---------------------------------------------------------------------------
def test_risk_level_mapping():
assert render.risk_level(0.9) == "critical"
assert render.risk_level(0.85) == "critical"
assert render.risk_level(0.72) == "high"
assert render.risk_level(0.7) == "high"
assert render.risk_level(0.5) == "medium"
assert render.risk_level(0.4) == "medium"
assert render.risk_level(0.1) == "low"
assert render.risk_level(0.0) == "low"
# ---------------------------------------------------------------------------
# md_escape
# ---------------------------------------------------------------------------
def test_md_escape_escapes_pipes_and_backticks():
escaped = render.md_escape("a|b`c")
assert "|" not in escaped.replace("\\|", "")
assert "\\|" in escaped
assert "\\`" in escaped
def test_md_escape_strips_control_chars_and_newlines():
escaped = render.md_escape("evil\x00name\nwith\rbreaks\x1b[31m")
assert "\x00" not in escaped
assert "\n" not in escaped
assert "\r" not in escaped
assert "\x1b" not in escaped
def test_md_escape_caps_length():
escaped = render.md_escape("x" * 500)
assert len(escaped) <= render._MAX_CELL
# ---------------------------------------------------------------------------
# render_markdown
# ---------------------------------------------------------------------------
def test_marker_is_first_line(report):
body = render.render_markdown(report)
assert body.splitlines()[0] == render.MARKER
def test_overall_risk_line(report):
body = render.render_markdown(report)
assert "**Overall risk: 0.72 (HIGH)**" in body
assert "3 changed function(s)/class(es)" in body
assert "2 affected flow(s)" in body
assert "1 test gap(s)" in body
def test_risk_table_lists_top_functions(report):
body = render.render_markdown(report)
assert "### Risk-scored changes" in body
assert render.md_escape("auth/session.py::rotate_token") in body
assert render.md_escape("auth/session.py::validate_session") in body
assert "| 0.72 | high |" in body
assert "| 0.41 | medium |" in body
assert "| 0.10 | low |" in body
# Untested function marked "no", tested ones "yes".
rotate_row = next(line for line in body.splitlines() if "rotate" in line and "| 0.72" in line)
assert rotate_row.rstrip().endswith("| no |")
validate_row = next(line for line in body.splitlines() if "| 0.41" in line)
assert validate_row.rstrip().endswith("| yes |")
def test_risk_table_location_includes_line_number(report):
body = render.render_markdown(report)
assert "auth/session.py:42" in body
def test_affected_flows_section(report):
body = render.render_markdown(report)
assert "### Affected execution flows" in body
assert render.md_escape("login_handler -> rotate_token") in body
assert "criticality 0.83" in body
assert "6 node(s) across 3 file(s)" in body
def test_test_gaps_section(report):
body = render.render_markdown(report)
assert "### Test gaps" in body
assert "(auth/session.py:42)" in body
def test_token_savings_line(report):
body = render.render_markdown(report)
assert "**Token savings:**" in body
assert "12,159" in body
assert "94%" in body
assert "estimated" in body
def test_token_savings_line_omitted_when_zero(report):
report["context_savings"] = {"estimated": True, "saved_tokens": 0, "saved_percent": 0}
body = render.render_markdown(report)
assert "**Token savings:**" not in body
def test_token_savings_line_omitted_when_absent(report):
del report["context_savings"]
body = render.render_markdown(report)
assert "**Token savings:**" not in body
def test_footer_powered_by(report):
body = render.render_markdown(report)
assert "Powered by [code-review-graph]" in body
assert "local-first" in body
def test_max_functions_cap(report):
body = render.render_markdown(report, max_functions=1)
assert render.md_escape("auth/session.py::rotate_token") in body
assert render.md_escape("auth/display.py::format_expiry") not in body
assert "and 2 more changed symbol(s)" in body
def test_max_flows_cap(report):
body = render.render_markdown(report, max_flows=1)
assert render.md_escape("login_handler -> rotate_token") in body
assert render.md_escape("cli_main -> validate_session") not in body
assert "and 1 more affected flow(s)" in body
def test_truncated_analysis_note(report):
report["functions_truncated"] = True
body = render.render_markdown(report)
assert "CRG_MAX_CHANGED_FUNCS" in body
def test_markdown_injection_in_names_is_escaped(report):
report["review_priorities"][0]["qualified_name"] = "x|y`z<script>"
body = render.render_markdown(report)
assert "x\\|y\\`z" in body
assert "<script>" not in body
def test_empty_report_renders_minimal_body():
body = render.render_markdown({})
assert body.startswith(render.MARKER)
assert "**Overall risk: 0.00 (LOW)**" in body
assert "### Risk-scored changes" not in body
assert "Powered by [code-review-graph]" in body
def test_body_size_capped():
huge = {
"risk_score": 0.5,
"review_priorities": [
{"qualified_name": f"mod.py::fn_{i}" + "x" * 100, "risk_score": 0.5,
"file_path": "mod.py", "line_start": i}
for i in range(5000)
],
}
body = render.render_markdown(huge, max_functions=5000)
assert len(body) < render._MAX_BODY + 1000
assert "Powered by [code-review-graph]" in body
# ---------------------------------------------------------------------------
# load_report / no-changes fallback
# ---------------------------------------------------------------------------
def test_load_report_accepts_valid_json(report):
assert render.load_report(FIXTURE.read_text(encoding="utf-8")) is not None
def test_load_report_rejects_plain_text():
assert render.load_report("No changes detected.") is None
def test_load_report_rejects_non_object_json():
assert render.load_report("[1, 2, 3]") is None
def test_render_no_changes_has_marker_and_footer():
body = render.render_no_changes()
assert body.splitlines()[0] == render.MARKER
assert "No analyzable code changes" in body
assert "Powered by [code-review-graph]" in body
# ---------------------------------------------------------------------------
# main(): file IO + risk gate
# ---------------------------------------------------------------------------
def test_main_writes_output_file(tmp_path):
out = tmp_path / "comment.md"
code = render.main(["--input", str(FIXTURE), "--output", str(out)])
assert code == 0
body = out.read_text(encoding="utf-8")
assert body.startswith(render.MARKER)
assert "Token savings" in body
def test_main_no_changes_input(tmp_path):
src = tmp_path / "report.json"
src.write_text("No changes detected.\n", encoding="utf-8")
out = tmp_path / "comment.md"
code = render.main(["--input", str(src), "--output", str(out)])
assert code == 0
assert "No analyzable code changes" in out.read_text(encoding="utf-8")
def test_main_missing_input_returns_2(tmp_path):
code = render.main(["--input", str(tmp_path / "nope.json"), "--quiet"])
assert code == 2
def test_fail_on_risk_high_breached(tmp_path):
code = render.main(["--input", str(FIXTURE), "--quiet", "--fail-on-risk", "high"])
assert code == 3
def test_fail_on_risk_critical_not_breached(tmp_path):
code = render.main(["--input", str(FIXTURE), "--quiet", "--fail-on-risk", "critical"])
assert code == 0
def test_fail_on_risk_none_passes(tmp_path):
code = render.main(["--input", str(FIXTURE), "--quiet", "--fail-on-risk", "none"])
assert code == 0
def test_fail_on_risk_passes_for_no_changes(tmp_path):
src = tmp_path / "report.json"
src.write_text("No changes detected.\n", encoding="utf-8")
code = render.main(["--input", str(src), "--quiet", "--fail-on-risk", "high"])
assert code == 0
def test_quiet_skips_output_file(tmp_path):
out = tmp_path / "comment.md"
code = render.main(["--input", str(FIXTURE), "--output", str(out), "--quiet"])
assert code == 0
assert not out.exists()
def test_cli_subprocess_stdout():
result = subprocess.run(
[sys.executable, str(SCRIPT), "--input", str(FIXTURE)],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0
assert result.stdout.startswith(render.MARKER)
assert "Powered by [code-review-graph]" in result.stdout
+632
View File
@@ -0,0 +1,632 @@
"""Tests for change impact analysis (changes.py)."""
import tempfile
from pathlib import Path
from unittest.mock import patch
from code_review_graph.changes import (
_parse_unified_diff,
analyze_changes,
compute_risk_score,
map_changes_to_nodes,
parse_git_diff_ranges,
)
from code_review_graph.flows import store_flows, trace_flows
from code_review_graph.graph import GraphStore
from code_review_graph.parser import EdgeInfo, NodeInfo
class TestChanges:
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
# -- helpers --
def _add_func(
self,
name: str,
path: str = "app.py",
parent: str | None = None,
is_test: bool = False,
line_start: int = 1,
line_end: int = 10,
extra: dict | None = None,
) -> int:
node = NodeInfo(
kind="Test" if is_test else "Function",
name=name,
file_path=path,
line_start=line_start,
line_end=line_end,
language="python",
parent_name=parent,
is_test=is_test,
extra=extra or {},
)
nid = self.store.upsert_node(node, file_hash="abc")
self.store.commit()
return nid
def _add_call(self, source_qn: str, target_qn: str, path: str = "app.py") -> None:
edge = EdgeInfo(
kind="CALLS",
source=source_qn,
target=target_qn,
file_path=path,
line=5,
)
self.store.upsert_edge(edge)
self.store.commit()
def _add_tested_by(self, test_qn: str, target_qn: str, path: str = "app.py") -> None:
edge = EdgeInfo(
kind="TESTED_BY",
source=test_qn,
target=target_qn,
file_path=path,
line=1,
)
self.store.upsert_edge(edge)
self.store.commit()
# ---------------------------------------------------------------
# parse_git_diff_ranges / _parse_unified_diff
# ---------------------------------------------------------------
def test_parse_unified_diff_basic(self):
"""Parses a simple unified diff into file -> range mappings."""
diff = (
"diff --git a/foo.py b/foo.py\n"
"--- a/foo.py\n"
"+++ b/foo.py\n"
"@@ -10,3 +10,5 @@ def foo():\n"
"+ new line\n"
"+ another\n"
)
result = _parse_unified_diff(diff)
assert "foo.py" in result
assert len(result["foo.py"]) == 1
start, end = result["foo.py"][0]
assert start == 10
assert end == 14 # 10 + 5 - 1
def test_parse_unified_diff_multiple_hunks(self):
"""Parses a diff with multiple hunks in one file."""
diff = (
"diff --git a/bar.py b/bar.py\n"
"--- a/bar.py\n"
"+++ b/bar.py\n"
"@@ -5,2 +5,3 @@ class Bar:\n"
"+ x\n"
"@@ -20,1 +21,4 @@ def method():\n"
"+ y\n"
)
result = _parse_unified_diff(diff)
assert "bar.py" in result
assert len(result["bar.py"]) == 2
assert result["bar.py"][0] == (5, 7) # 5 + 3 - 1
assert result["bar.py"][1] == (21, 24) # 21 + 4 - 1
def test_parse_unified_diff_single_line(self):
"""Parses a diff where count is omitted (single line change)."""
diff = (
"--- a/x.py\n"
"+++ b/x.py\n"
"@@ -1 +1 @@\n"
"+changed\n"
)
result = _parse_unified_diff(diff)
assert "x.py" in result
assert result["x.py"][0] == (1, 1)
def test_parse_unified_diff_deletion_only(self):
"""Handles pure deletion hunks (+start,0)."""
diff = (
"--- a/del.py\n"
"+++ b/del.py\n"
"@@ -10,3 +10,0 @@ some context\n"
)
result = _parse_unified_diff(diff)
assert "del.py" in result
# Count=0 means deletion, start=end
assert result["del.py"][0] == (10, 10)
def test_parse_unified_diff_multiple_files(self):
"""Parses a diff spanning two files."""
diff = (
"--- a/a.py\n"
"+++ b/a.py\n"
"@@ -1,2 +1,3 @@\n"
"+x\n"
"--- a/b.py\n"
"+++ b/b.py\n"
"@@ -5,1 +5,2 @@\n"
"+y\n"
)
result = _parse_unified_diff(diff)
assert "a.py" in result
assert "b.py" in result
def test_parse_git_diff_ranges_error_handling(self):
"""Returns empty dict when git command fails."""
result = parse_git_diff_ranges("/nonexistent/path", base="HEAD~1")
assert result == {}
# ---------------------------------------------------------------
# map_changes_to_nodes
# ---------------------------------------------------------------
def test_map_changes_to_nodes_overlap(self):
"""Finds nodes whose line ranges overlap the changed lines."""
self._add_func("func_a", path="app.py", line_start=5, line_end=15)
self._add_func("func_b", path="app.py", line_start=20, line_end=30)
self._add_func("func_c", path="app.py", line_start=35, line_end=45)
# Change lines 10-25: overlaps func_a (5-15) and func_b (20-30)
changed_ranges = {"app.py": [(10, 25)]}
nodes = map_changes_to_nodes(self.store, changed_ranges)
names = {n.name for n in nodes}
assert "func_a" in names
assert "func_b" in names
assert "func_c" not in names
def test_map_changes_to_nodes_no_overlap(self):
"""Returns empty when no nodes overlap the changed lines."""
self._add_func("func_a", path="app.py", line_start=5, line_end=10)
changed_ranges = {"app.py": [(50, 60)]}
nodes = map_changes_to_nodes(self.store, changed_ranges)
assert len(nodes) == 0
def test_map_changes_to_nodes_deduplication(self):
"""Deduplicates nodes by qualified name when overlapping multiple ranges."""
self._add_func("func_a", path="app.py", line_start=5, line_end=20)
# Two ranges that both overlap func_a.
changed_ranges = {"app.py": [(6, 8), (15, 18)]}
nodes = map_changes_to_nodes(self.store, changed_ranges)
assert len(nodes) == 1
assert nodes[0].name == "func_a"
def test_map_changes_to_nodes_different_files(self):
"""Maps changes across different files."""
self._add_func("func_x", path="x.py", line_start=1, line_end=10)
self._add_func("func_y", path="y.py", line_start=1, line_end=10)
changed_ranges = {
"x.py": [(3, 5)],
"y.py": [(3, 5)],
}
nodes = map_changes_to_nodes(self.store, changed_ranges)
names = {n.name for n in nodes}
assert "func_x" in names
assert "func_y" in names
# ---------------------------------------------------------------
# compute_risk_score
# ---------------------------------------------------------------
def test_risk_score_range(self):
"""Risk score is always between 0 and 1."""
self._add_func("simple_func")
node = self.store.get_node("app.py::simple_func")
assert node is not None
score = compute_risk_score(self.store, node)
assert 0.0 <= score <= 1.0
def test_risk_score_untested_is_higher(self):
"""Untested functions score higher than tested ones."""
self._add_func("untested_func", path="a.py", line_start=1, line_end=10)
self._add_func("tested_func", path="b.py", line_start=1, line_end=10)
self._add_func("test_tested_func", path="test_b.py", is_test=True)
self._add_tested_by("test_b.py::test_tested_func", "b.py::tested_func", "test_b.py")
untested = self.store.get_node("a.py::untested_func")
tested = self.store.get_node("b.py::tested_func")
assert untested is not None
assert tested is not None
untested_score = compute_risk_score(self.store, untested)
tested_score = compute_risk_score(self.store, tested)
# Untested gets 0.30, tested gets 0.05 for test coverage component.
assert untested_score > tested_score
def test_risk_score_security_keywords_boost(self):
"""Functions with security keywords score higher."""
self._add_func("process_data", path="a.py")
self._add_func("verify_auth_token", path="b.py")
normal = self.store.get_node("a.py::process_data")
secure = self.store.get_node("b.py::verify_auth_token")
assert normal is not None
assert secure is not None
normal_score = compute_risk_score(self.store, normal)
secure_score = compute_risk_score(self.store, secure)
assert secure_score > normal_score
def test_risk_score_with_callers(self):
"""Functions with many callers get a caller count bonus."""
self._add_func("popular_func", path="lib.py")
for i in range(10):
caller_name = f"caller_{i}"
self._add_func(caller_name, path=f"c{i}.py")
self._add_call(f"c{i}.py::{caller_name}", "lib.py::popular_func", f"c{i}.py")
self._add_func("lonely_func", path="other.py")
popular = self.store.get_node("lib.py::popular_func")
lonely = self.store.get_node("other.py::lonely_func")
assert popular is not None
assert lonely is not None
popular_score = compute_risk_score(self.store, popular)
lonely_score = compute_risk_score(self.store, lonely)
assert popular_score > lonely_score
def test_risk_score_with_flow_membership(self):
"""Nodes participating in flows get a flow participation bonus."""
# Build a flow: entry -> helper
self._add_func("entry", path="app.py", line_start=1, line_end=10)
self._add_func("helper", path="app.py", line_start=15, line_end=25)
self._add_call("app.py::entry", "app.py::helper")
flows = trace_flows(self.store)
store_flows(self.store, flows)
# helper participates in a flow.
helper = self.store.get_node("app.py::helper")
assert helper is not None
# An isolated node with no flows.
self._add_func("isolated", path="iso.py")
isolated = self.store.get_node("iso.py::isolated")
assert isolated is not None
helper_score = compute_risk_score(self.store, helper)
isolated_score = compute_risk_score(self.store, isolated)
# helper should have flow participation bonus.
assert helper_score >= isolated_score
def test_risk_score_weighted_by_flow_criticality(self):
"""Nodes in high-criticality flows score higher than low-criticality."""
# Build two separate flows with different criticality
self._add_func("hi_entry", path="hi.py", line_start=1, line_end=5)
self._add_func("hi_func", path="hi.py", line_start=10, line_end=20)
self._add_call("hi.py::hi_entry", "hi.py::hi_func")
self._add_func("lo_entry", path="lo.py", line_start=1, line_end=5)
self._add_func("lo_func", path="lo.py", line_start=10, line_end=20)
self._add_call("lo.py::lo_entry", "lo.py::lo_func")
flows = trace_flows(self.store)
store_flows(self.store, flows)
# Manually set different criticality values
self.store._conn.execute(
"UPDATE flows SET criticality = 0.9 "
"WHERE name = 'hi_entry'"
)
self.store._conn.execute(
"UPDATE flows SET criticality = 0.1 "
"WHERE name = 'lo_entry'"
)
self.store.commit()
hi = self.store.get_node("hi.py::hi_func")
lo = self.store.get_node("lo.py::lo_func")
assert hi and lo
hi_score = compute_risk_score(self.store, hi)
lo_score = compute_risk_score(self.store, lo)
assert hi_score > lo_score, (
f"High-criticality flow node ({hi_score}) should score "
f"higher than low-criticality ({lo_score})"
)
# ---------------------------------------------------------------
# analyze_changes
# ---------------------------------------------------------------
def test_analyze_changes_returns_expected_keys(self):
"""analyze_changes returns all expected top-level keys."""
self._add_func("changed_func", path="app.py", line_start=1, line_end=10)
result = analyze_changes(
self.store,
changed_files=["app.py"],
changed_ranges={"app.py": [(1, 10)]},
)
assert "summary" in result
assert "risk_score" in result
assert "changed_functions" in result
assert "affected_flows" in result
assert "test_gaps" in result
assert "review_priorities" in result
def test_analyze_changes_risk_score_range(self):
"""Overall risk score is between 0 and 1."""
self._add_func("func_a", path="app.py", line_start=1, line_end=10)
result = analyze_changes(
self.store,
changed_files=["app.py"],
changed_ranges={"app.py": [(1, 10)]},
)
assert 0.0 <= result["risk_score"] <= 1.0
def test_analyze_detects_test_gaps(self):
"""Changed functions without TESTED_BY edges are flagged as test gaps."""
self._add_func("untested_a", path="app.py", line_start=1, line_end=10)
self._add_func("untested_b", path="app.py", line_start=15, line_end=25)
self._add_func("tested_c", path="app.py", line_start=30, line_end=40)
# Only tested_c has a test.
self._add_func("test_c", path="test_app.py", is_test=True)
self._add_tested_by("test_app.py::test_c", "app.py::tested_c", "test_app.py")
result = analyze_changes(
self.store,
changed_files=["app.py"],
changed_ranges={"app.py": [(1, 40)]},
)
gap_names = {g["name"] for g in result["test_gaps"]}
assert "untested_a" in gap_names
assert "untested_b" in gap_names
assert "tested_c" not in gap_names
def test_analyze_changes_with_flows(self):
"""analyze_changes detects affected flows."""
self._add_func("handler", path="routes.py", line_start=1, line_end=10)
self._add_func("service", path="services.py", line_start=1, line_end=10)
self._add_call("routes.py::handler", "services.py::service", "routes.py")
flows = trace_flows(self.store)
store_flows(self.store, flows)
result = analyze_changes(
self.store,
changed_files=["services.py"],
changed_ranges={"services.py": [(1, 10)]},
)
assert len(result["affected_flows"]) >= 1
def test_analyze_changes_review_priorities_ordered(self):
"""Review priorities are ordered by descending risk score."""
# Create several functions with varying risk levels.
self._add_func("safe_func", path="app.py", line_start=1, line_end=5)
self._add_func("auth_handler", path="app.py", line_start=10, line_end=20)
result = analyze_changes(
self.store,
changed_files=["app.py"],
changed_ranges={"app.py": [(1, 20)]},
)
priorities = result["review_priorities"]
if len(priorities) >= 2:
for i in range(len(priorities) - 1):
assert priorities[i]["risk_score"] >= priorities[i + 1]["risk_score"]
def test_analyze_changes_fallback_no_ranges(self):
"""Falls back to all nodes in files when no ranges provided."""
self._add_func("func_a", path="app.py", line_start=1, line_end=10)
self._add_func("func_b", path="app.py", line_start=15, line_end=25)
result = analyze_changes(
self.store,
changed_files=["app.py"],
changed_ranges=None,
)
# Should still find functions even without ranges.
assert len(result["changed_functions"]) >= 1
# ---------------------------------------------------------------
# detect_changes_func (integration)
# ---------------------------------------------------------------
def test_detect_changes_tool_no_changes(self):
"""detect_changes_func returns clean result when no changes detected."""
from code_review_graph.tools import detect_changes_func
# Patch _get_store to use our test store,
# and get_changed_files/get_staged_and_unstaged to return empty.
with (
patch("code_review_graph.tools.review._get_store") as mock_get_store,
patch("code_review_graph.tools.review.get_changed_files", return_value=[]),
patch("code_review_graph.tools.review.get_staged_and_unstaged", return_value=[]),
):
mock_get_store.return_value = (self.store, Path("/fake/repo"))
# Prevent the store from being closed by the tool
# (our teardown handles it).
self.store.close = lambda: None
result = detect_changes_func(base="HEAD~1", repo_root="/fake/repo")
assert result["status"] == "ok"
assert result["risk_score"] == 0.0
assert result["changed_functions"] == []
assert result["test_gaps"] == []
def test_detect_changes_tool_with_changes(self):
"""detect_changes_func returns full analysis for changed files."""
from code_review_graph.tools import detect_changes_func
self._add_func("my_func", path="/fake/repo/app.py", line_start=1, line_end=10)
with (
patch("code_review_graph.tools.review._get_store") as mock_get_store,
patch("code_review_graph.tools.review.get_changed_files", return_value=["app.py"]),
patch(
"code_review_graph.tools.review.parse_git_diff_ranges",
return_value={"app.py": [(1, 10)]},
),
):
mock_get_store.return_value = (self.store, Path("/fake/repo"))
self.store.close = lambda: None
result = detect_changes_func(base="HEAD~1", repo_root="/fake/repo")
assert result["status"] == "ok"
assert "changed_functions" in result
assert "risk_score" in result
assert "test_gaps" in result
assert "review_priorities" in result
class TestAnalyzeChangesFunctionCap:
"""Regression tests for O(N) slowdown when PR touches many functions."""
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _add_funcs(self, count: int, path: str = "app.py") -> None:
for i in range(count):
node = NodeInfo(
kind="Function", name=f"func_{i}", file_path=path,
line_start=i * 10 + 1, line_end=i * 10 + 9, language="python",
)
self.store.upsert_node(node, file_hash="abc")
self.store.commit()
def test_changed_funcs_capped(self, monkeypatch):
"""analyze_changes processes at most CRG_MAX_CHANGED_FUNCS functions."""
monkeypatch.setenv("CRG_MAX_CHANGED_FUNCS", "10")
self._add_funcs(20)
result = analyze_changes(self.store, changed_files=["app.py"])
assert len(result["changed_functions"]) == 10
assert result["functions_truncated"] is True
assert "CRG_MAX_CHANGED_FUNCS" in result["summary"]
def test_no_truncation_below_cap(self, monkeypatch):
"""analyze_changes processes all functions when count is below cap."""
monkeypatch.setenv("CRG_MAX_CHANGED_FUNCS", "50")
self._add_funcs(5)
result = analyze_changes(self.store, changed_files=["app.py"])
assert len(result["changed_functions"]) == 5
assert result["functions_truncated"] is False
class TestAnalyzeChangesInternalParseRemap:
"""Regression tests for #528: CLI detect-changes mapped 0 functions.
The graph stores absolute native paths (see ``full_build``), but
``parse_diff_ranges`` keys are forward-slash paths relative to the
repo root. On Windows the LIKE-suffix fallback can never bridge
"src/app.py" to "C:\\repo\\src\\app.py", so analyze_changes must remap
internally-parsed diff keys to absolute native paths — mirroring what
tools/review.py already does for the MCP path.
"""
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _add_func_at(self, abs_path: str) -> None:
node = NodeInfo(
kind="Function", name="greet", file_path=abs_path,
line_start=1, line_end=10, language="python",
)
self.store.upsert_node(node, file_hash="abc")
self.store.commit()
def _spy_map_changes(self, captured: dict):
"""Wrap the real map_changes_to_nodes, capturing changed_ranges."""
def _spy(store, changed_ranges):
captured["ranges"] = changed_ranges
return map_changes_to_nodes(store, changed_ranges)
return _spy
def test_internal_parse_remaps_relative_keys_to_absolute(self, tmp_path):
"""Forward-slash relative diff keys become absolute native paths."""
abs_path = str(tmp_path / "src" / "app.py")
self._add_func_at(abs_path)
captured: dict = {}
with (
patch(
"code_review_graph.changes.parse_diff_ranges",
return_value={"src/app.py": [(2, 3)]},
),
patch(
"code_review_graph.changes.map_changes_to_nodes",
side_effect=self._spy_map_changes(captured),
),
):
result = analyze_changes(
self.store,
changed_files=["src/app.py"],
repo_root=str(tmp_path),
)
# The internal-parse branch must produce absolute keys under root.
assert list(captured["ranges"]) == [abs_path]
assert captured["ranges"][abs_path] == [(2, 3)]
# And those keys must hit the absolute-stored node directly.
assert any(f["name"] == "greet" for f in result["changed_functions"])
def test_internal_parse_preserves_already_absolute_keys(self, tmp_path):
"""Keys that are already absolute are not double-joined."""
abs_path = str(tmp_path / "src" / "app.py")
self._add_func_at(abs_path)
captured: dict = {}
with (
patch(
"code_review_graph.changes.parse_diff_ranges",
return_value={abs_path: [(2, 3)]},
),
patch(
"code_review_graph.changes.map_changes_to_nodes",
side_effect=self._spy_map_changes(captured),
),
):
result = analyze_changes(
self.store,
changed_files=[abs_path],
repo_root=str(tmp_path),
)
assert list(captured["ranges"]) == [abs_path]
assert any(f["name"] == "greet" for f in result["changed_functions"])
def test_explicit_changed_ranges_not_remapped(self, tmp_path):
"""The explicit changed_ranges path (MCP) must stay untouched."""
node = NodeInfo(
kind="Function", name="rel_func", file_path="app.py",
line_start=1, line_end=10, language="python",
)
self.store.upsert_node(node, file_hash="abc")
self.store.commit()
captured: dict = {}
with (
patch(
"code_review_graph.changes.map_changes_to_nodes",
side_effect=self._spy_map_changes(captured),
),
):
result = analyze_changes(
self.store,
changed_files=["app.py"],
changed_ranges={"app.py": [(2, 3)]},
repo_root=str(tmp_path),
)
# No remapping: keys passed through exactly as the caller gave them.
assert list(captured["ranges"]) == ["app.py"]
assert any(f["name"] == "rel_func" for f in result["changed_functions"])
+344
View File
@@ -0,0 +1,344 @@
"""Tests for CLI helpers and MCP serve command wiring."""
import json
import logging
import sys
from importlib.metadata import PackageNotFoundError
from unittest.mock import MagicMock, patch
from code_review_graph import cli
def test_get_version_falls_back_to_package_attr_when_metadata_missing(
monkeypatch, caplog,
):
"""When importlib.metadata can't find the dist, fall back to __version__.
This matters on filesystems where iCloud / OneDrive leave orphan
dist-info dirs that confuse the metadata lookup. Before v2.3.5 the
fallback returned the literal string "dev", which produced confusing
output for installed users whose lookup happened to fail.
"""
def _raise_package_not_found(_dist_name: str) -> str:
raise PackageNotFoundError("code-review-graph")
monkeypatch.setattr(cli, "pkg_version", _raise_package_not_found)
with caplog.at_level(logging.DEBUG, logger="code_review_graph.cli"):
version = cli._get_version()
# Falls back to the package's __version__, not "dev"
from code_review_graph import __version__ as expected
assert version == expected
assert "Package metadata unavailable" in caplog.text
def test_get_version_returns_dev_when_both_sources_fail(monkeypatch, caplog):
"""The literal "dev" fallback still fires when __version__ also fails."""
def _raise_package_not_found(_dist_name: str) -> str:
raise PackageNotFoundError("code-review-graph")
monkeypatch.setattr(cli, "pkg_version", _raise_package_not_found)
import code_review_graph
monkeypatch.delattr(code_review_graph, "__version__", raising=False)
with caplog.at_level(logging.DEBUG, logger="code_review_graph.cli"):
version = cli._get_version()
assert version == "dev"
class TestServeCommand:
def test_serve_passes_auto_watch_flag(self):
argv = [
"code-review-graph",
"serve",
"--repo",
"repo-root",
"--auto-watch",
]
with patch.object(sys, "argv", argv):
with patch("code_review_graph.main.main") as mock_serve:
cli.main()
mock_serve.assert_called_once_with(
repo_root="repo-root",
auto_watch=True,
tools=None,
)
def test_mcp_alias_maps_to_serve(self):
argv = [
"code-review-graph",
"mcp",
"--repo",
"repo-root",
]
with patch.object(sys, "argv", argv):
with patch("code_review_graph.main.main") as mock_serve:
cli.main()
mock_serve.assert_called_once_with(
repo_root="repo-root",
auto_watch=False,
)
class TestWatchInteraction:
def test_watch_exits_when_lock_is_held(self):
argv = ["code-review-graph", "watch", "--repo", "repo-root"]
with patch.object(sys, "argv", argv):
with patch("code_review_graph.graph.GraphStore") as mock_store:
mock_store.return_value = MagicMock()
with patch("code_review_graph.incremental.get_db_path") as mock_db:
mock_db.return_value = MagicMock()
with patch("code_review_graph.incremental.watch") as mock_watch:
mock_watch.side_effect = RuntimeError("watcher already running")
try:
cli.main()
assert False, "Expected SystemExit"
except SystemExit as exc:
assert exc.code == 1
class TestBuildUpdateCommands:
def test_build_skip_postprocess_does_not_run_extra_cli_postprocess(self):
argv = [
"code-review-graph",
"build",
"--skip-postprocess",
"--repo",
"repo-root",
]
result = {
"files_parsed": 1,
"total_nodes": 2,
"total_edges": 1,
"postprocess_level": "none",
}
with patch.object(sys, "argv", argv):
with patch("code_review_graph.graph.GraphStore") as mock_store:
mock_store.return_value = MagicMock()
with patch("code_review_graph.incremental.get_db_path") as mock_db:
mock_db.return_value = MagicMock()
with patch(
"code_review_graph.tools.build.build_or_update_graph",
return_value=result,
) as mock_build:
with patch(
"code_review_graph.postprocessing.run_post_processing",
) as mock_postprocess:
cli.main()
mock_build.assert_called_once_with(
full_rebuild=True,
repo_root="repo-root",
postprocess="none",
)
mock_postprocess.assert_not_called()
def test_update_skip_flows_does_not_run_extra_cli_postprocess(self):
argv = [
"code-review-graph",
"update",
"--skip-flows",
"--repo",
"repo-root",
]
result = {
"files_updated": 1,
"total_nodes": 2,
"total_edges": 1,
"postprocess_level": "minimal",
}
with patch.object(sys, "argv", argv):
with patch("code_review_graph.graph.GraphStore") as mock_store:
mock_store.return_value = MagicMock()
with patch("code_review_graph.incremental.get_db_path") as mock_db:
mock_db.return_value = MagicMock()
with patch(
"code_review_graph.tools.build.build_or_update_graph",
return_value=result,
) as mock_build:
with patch(
"code_review_graph.postprocessing.run_post_processing",
) as mock_postprocess:
cli.main()
mock_build.assert_called_once_with(
full_rebuild=False,
repo_root="repo-root",
base="HEAD~1",
postprocess="minimal",
)
mock_postprocess.assert_not_called()
class TestDetectChangesCommand:
def test_brief_output_includes_token_savings_panel(self, tmp_path, capsys):
"""v2.3.5: --brief output renders a boxed Token Savings panel.
Replaces the v2.3.4 one-line `Estimated context saved: …` format.
The panel must include the title, the saved-tokens line, the
percent suffix, and box borders.
"""
repo = tmp_path / "repo"
repo.mkdir()
(repo / ".git").mkdir()
(repo / "app.py").write_text("x" * 2000, encoding="utf-8")
argv = [
"code-review-graph",
"detect-changes",
"--repo",
str(repo),
"--brief",
]
with patch.object(sys, "argv", argv):
with patch("code_review_graph.graph.GraphStore") as mock_store:
mock_store.return_value = MagicMock()
with patch("code_review_graph.incremental.get_db_path") as mock_db:
mock_db.return_value = MagicMock()
with patch(
"code_review_graph.incremental.get_changed_files",
return_value=["app.py"],
):
with patch(
"code_review_graph.changes.analyze_changes",
return_value={"summary": "summary only"},
):
cli.main()
output = capsys.readouterr().out
assert "summary only" in output
# Panel structure: title, the three core rows, and box borders.
assert "Token Savings" in output
assert "Full context would be:" in output
assert "Graph context used:" in output
assert "Saved:" in output
# Box drawing characters from format_context_savings_panel
assert "" in output and "" in output
def test_json_output_includes_compact_savings_metadata(self, tmp_path, capsys):
repo = tmp_path / "repo"
repo.mkdir()
(repo / ".git").mkdir()
(repo / "app.py").write_text("x" * 2000, encoding="utf-8")
argv = [
"code-review-graph",
"detect-changes",
"--repo",
str(repo),
]
with patch.object(sys, "argv", argv):
with patch("code_review_graph.graph.GraphStore") as mock_store:
mock_store.return_value = MagicMock()
with patch("code_review_graph.incremental.get_db_path") as mock_db:
mock_db.return_value = MagicMock()
with patch(
"code_review_graph.incremental.get_changed_files",
return_value=["app.py"],
):
with patch(
"code_review_graph.changes.analyze_changes",
return_value={"summary": "json summary"},
):
cli.main()
result = json.loads(capsys.readouterr().out)
assert set(result["context_savings"]) == {
"estimated",
"saved_tokens",
"saved_percent",
}
class TestDetectChangesEndToEnd:
"""Regression test for #528: CLI detect-changes mapped 0 functions.
The graph stores absolute native paths, but the CLI path let
analyze_changes parse the diff internally, producing forward-slash
relative keys that never matched on Windows. This exercises the full
pipeline on a real tmp git repo with a committed change.
"""
@staticmethod
def _git(repo, *args):
import subprocess
subprocess.run(
["git", "-C", str(repo), "-c", "user.email=t@example.com",
"-c", "user.name=Test", "-c", "commit.gpgsign=false", *args],
check=True,
capture_output=True,
stdin=subprocess.DEVNULL,
timeout=30,
)
def test_detect_changes_maps_committed_change_to_functions(
self, tmp_path, capsys, monkeypatch,
):
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
monkeypatch.delenv("CRG_REPO_ROOT", raising=False)
repo = tmp_path / "repo"
src = repo / "src"
src.mkdir(parents=True)
app = src / "app.py"
app.write_text(
"def greet(name):\n"
" message = 'hello ' + name\n"
" return message\n"
"\n"
"def farewell(name):\n"
" return 'bye ' + name\n",
encoding="utf-8",
)
self._git(repo, "init", "-q")
self._git(repo, "add", ".")
self._git(repo, "commit", "-q", "-m", "initial")
# Commit a change inside greet() so HEAD~1..HEAD touches lines 1-3.
app.write_text(
"def greet(name):\n"
" message = 'hi there ' + name\n"
" return message.upper()\n"
"\n"
"def farewell(name):\n"
" return 'bye ' + name\n",
encoding="utf-8",
)
self._git(repo, "add", ".")
self._git(repo, "commit", "-q", "-m", "change greet")
# Build the graph after the change (stores absolute native paths).
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import full_build, get_db_path
store = GraphStore(get_db_path(repo))
try:
full_build(repo, store)
finally:
store.close()
argv = ["code-review-graph", "detect-changes", "--repo", str(repo)]
with patch.object(sys, "argv", argv):
cli.main()
out = capsys.readouterr().out
result = json.loads(out[out.index("{"):])
# The diff must map to >0 functions — not silently come up empty.
names = {f["name"] for f in result["changed_functions"]}
assert "greet" in names
# The token-savings metadata must not be the misleading
# 100%-on-empty case (tiny response because nothing was mapped).
savings = result["context_savings"]
assert result["changed_functions"]
assert savings["saved_percent"] < 100
+98
View File
@@ -0,0 +1,98 @@
"""Tests for install CLI platform-specific behavior."""
from __future__ import annotations
import argparse
from pathlib import Path
from code_review_graph.cli import _handle_init
def _args(tmp_path: Path, platform: str) -> argparse.Namespace:
return argparse.Namespace(
repo=str(tmp_path),
dry_run=False,
platform=platform,
yes=True,
no_instructions=True,
no_skills=False,
no_hooks=False,
)
def test_handle_init_codex_skips_claude_skills(monkeypatch, tmp_path, capsys):
monkeypatch.setattr(
"code_review_graph.incremental.find_repo_root",
lambda: tmp_path,
)
monkeypatch.setattr(
"code_review_graph.incremental.ensure_repo_gitignore_excludes_crg",
lambda repo_root: "created",
)
monkeypatch.setattr(
"code_review_graph.skills.install_platform_configs",
lambda repo_root, target, dry_run=False: ["Codex"],
)
called = {"generate_skills": False, "codex_hooks": False, "git_hook": False}
def _generate_skills(repo_root):
called["generate_skills"] = True
return repo_root / ".claude" / "skills"
def _install_codex_hooks(repo_root):
called["codex_hooks"] = True
return Path("/tmp/fake-codex-hooks.json")
def _install_git_hook(repo_root):
called["git_hook"] = True
return repo_root / ".git" / "hooks" / "pre-commit"
monkeypatch.setattr("code_review_graph.skills.generate_skills", _generate_skills)
monkeypatch.setattr("code_review_graph.skills.install_codex_hooks", _install_codex_hooks)
monkeypatch.setattr("code_review_graph.skills.install_git_hook", _install_git_hook)
_handle_init(_args(tmp_path, "codex"))
out = capsys.readouterr().out
assert called["generate_skills"] is False
assert called["codex_hooks"] is True
assert called["git_hook"] is True
assert "Installed Codex hooks" in out
def test_handle_init_cursor_installs_cursor_hooks(monkeypatch, tmp_path, capsys):
monkeypatch.setattr(
"code_review_graph.incremental.find_repo_root",
lambda: tmp_path,
)
monkeypatch.setattr(
"code_review_graph.incremental.ensure_repo_gitignore_excludes_crg",
lambda repo_root: "created",
)
monkeypatch.setattr(
"code_review_graph.skills.install_platform_configs",
lambda repo_root, target, dry_run=False: ["Cursor"],
)
monkeypatch.setitem(
__import__("code_review_graph.skills", fromlist=["PLATFORMS"]).PLATFORMS,
"cursor",
{
**__import__("code_review_graph.skills", fromlist=["PLATFORMS"]).PLATFORMS["cursor"],
"detect": lambda: True,
},
)
called = {"cursor_hooks": False}
def _install_cursor_hooks():
called["cursor_hooks"] = True
return Path("/tmp/fake-cursor-hooks.json")
monkeypatch.setattr("code_review_graph.skills.install_cursor_hooks", _install_cursor_hooks)
_handle_init(_args(tmp_path, "cursor"))
out = capsys.readouterr().out
assert called["cursor_hooks"] is True
assert "Installed Cursor hooks" in out
+592
View File
@@ -0,0 +1,592 @@
"""Tests for community/cluster detection."""
import tempfile
from pathlib import Path
import pytest
from code_review_graph.communities import (
IGRAPH_AVAILABLE,
_compute_cohesion,
_compute_cohesion_batch,
_detect_file_based,
_generate_community_name,
detect_communities,
get_architecture_overview,
get_communities,
incremental_detect_communities,
store_communities,
)
from code_review_graph.graph import GraphEdge, GraphNode, GraphStore
from code_review_graph.parser import EdgeInfo, NodeInfo
class TestCommunities:
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _seed_two_clusters(self):
"""Seed two distinct clusters: auth (auth.py) and db (db.py)."""
# Auth cluster
self.store.upsert_node(
NodeInfo(
kind="File", name="auth.py", file_path="auth.py",
line_start=1, line_end=100, language="python",
), file_hash="a1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="login", file_path="auth.py",
line_start=5, line_end=20, language="python",
), file_hash="a1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="logout", file_path="auth.py",
line_start=25, line_end=40, language="python",
), file_hash="a1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="check_token", file_path="auth.py",
line_start=45, line_end=60, language="python",
), file_hash="a1"
)
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="auth.py::login",
target="auth.py::check_token", file_path="auth.py", line=10,
))
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="auth.py::logout",
target="auth.py::check_token", file_path="auth.py", line=30,
))
# DB cluster
self.store.upsert_node(
NodeInfo(
kind="File", name="db.py", file_path="db.py",
line_start=1, line_end=100, language="python",
), file_hash="b1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="connect", file_path="db.py",
line_start=5, line_end=20, language="python",
), file_hash="b1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="query", file_path="db.py",
line_start=25, line_end=40, language="python",
), file_hash="b1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="close", file_path="db.py",
line_start=45, line_end=60, language="python",
), file_hash="b1"
)
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="db.py::query",
target="db.py::connect", file_path="db.py", line=30,
))
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="db.py::close",
target="db.py::connect", file_path="db.py", line=50,
))
# One cross-cluster edge
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="auth.py::login",
target="db.py::query", file_path="auth.py", line=15,
))
self.store.commit()
def test_detect_communities_returns_list(self):
"""detect_communities returns a list."""
self._seed_two_clusters()
result = detect_communities(self.store, min_size=2)
assert isinstance(result, list)
@pytest.mark.skipif(not IGRAPH_AVAILABLE, reason="igraph not installed")
def test_detect_finds_clusters(self):
"""With clear clusters and igraph, finds >= 2 communities."""
self._seed_two_clusters()
result = detect_communities(self.store, min_size=2)
assert len(result) >= 2
def test_community_has_required_fields(self):
"""Each community dict has required fields: name, size, cohesion, members."""
self._seed_two_clusters()
result = detect_communities(self.store, min_size=2)
assert len(result) > 0
for comm in result:
assert "name" in comm
assert "size" in comm
assert "cohesion" in comm
assert "members" in comm
assert isinstance(comm["name"], str)
assert isinstance(comm["size"], int)
assert isinstance(comm["cohesion"], (int, float))
assert isinstance(comm["members"], list)
def test_store_and_retrieve_communities(self):
"""Communities can be stored and retrieved round-trip."""
self._seed_two_clusters()
communities = detect_communities(self.store, min_size=2)
assert len(communities) > 0
count = store_communities(self.store, communities)
assert count == len(communities)
retrieved = get_communities(self.store)
assert len(retrieved) == len(communities)
for comm in retrieved:
assert "id" in comm
assert "name" in comm
assert "size" in comm
def test_architecture_overview(self):
"""Architecture overview has required keys."""
self._seed_two_clusters()
communities = detect_communities(self.store, min_size=2)
store_communities(self.store, communities)
overview = get_architecture_overview(self.store)
assert "communities" in overview
assert "cross_community_edges" in overview
assert "warnings" in overview
assert isinstance(overview["communities"], list)
assert isinstance(overview["cross_community_edges"], list)
assert isinstance(overview["warnings"], list)
def test_architecture_overview_excludes_tested_by_coupling(self):
"""TESTED_BY edges do not count toward coupling warnings."""
self._seed_two_clusters()
communities = detect_communities(self.store, min_size=2)
store_communities(self.store, communities)
# Add many TESTED_BY cross-community edges (well above the threshold of 10)
for i in range(20):
self.store.upsert_edge(EdgeInfo(
kind="TESTED_BY", source=f"auth.py::login",
target=f"db.py::query", file_path="auth.py", line=i + 100,
))
self.store.commit()
overview = get_architecture_overview(self.store)
# Warnings should not include any that are purely from TESTED_BY edges
for w in overview["warnings"]:
assert "TESTED_BY" not in w
def test_architecture_overview_excludes_test_community_warnings(self):
"""Warnings involving test-dominated communities are filtered out."""
self._seed_two_clusters()
communities = detect_communities(self.store, min_size=2)
store_communities(self.store, communities)
# Manually insert a test-named community with high cross-coupling
conn = self.store._conn
cursor = conn.execute(
"INSERT INTO communities (name, level, cohesion, size, dominant_language, description)"
" VALUES (?, 0, 0.5, 10, 'typescript', 'Test community')",
("handler-it:should",),
)
test_comm_id = cursor.lastrowid
# Assign some nodes to this community (reuse existing node)
conn.execute(
"UPDATE nodes SET community_id = ? WHERE name = 'login'",
(test_comm_id,),
)
conn.commit()
overview = get_architecture_overview(self.store)
for w in overview["warnings"]:
assert "it:should" not in w, f"Test community should be filtered: {w}"
def test_fallback_file_communities(self):
"""File-based fallback produces communities grouped by file."""
self._seed_two_clusters()
# Gather nodes and edges for file-based detection
all_edges = self.store.get_all_edges()
nodes = []
for fp in self.store.get_all_files():
nodes.extend(self.store.get_nodes_by_file(fp))
result = _detect_file_based(nodes, all_edges, min_size=2)
assert isinstance(result, list)
assert len(result) >= 2
for comm in result:
assert "name" in comm
assert "size" in comm
assert comm["size"] >= 2
def test_community_naming(self):
"""Community naming produces non-empty names."""
self._seed_two_clusters()
result = detect_communities(self.store, min_size=2)
for comm in result:
assert comm["name"]
assert len(comm["name"]) > 0
def test_community_naming_with_dominant_class(self):
"""When a class dominates (>40%), it appears in the name."""
nodes = [
GraphNode(
id=1, kind="Class", name="AuthService", qualified_name="auth.py::AuthService",
file_path="auth.py", line_start=1, line_end=100, language="python",
parent_name=None, params=None, return_type=None, is_test=False,
file_hash="x", extra={},
),
GraphNode(
id=2, kind="Function", name="login", qualified_name="auth.py::AuthService.login",
file_path="auth.py", line_start=10, line_end=20, language="python",
parent_name="AuthService", params=None, return_type=None, is_test=False,
file_hash="x", extra={},
),
]
name = _generate_community_name(nodes)
assert name # non-empty
assert "authservice" in name.lower() or "auth" in name.lower()
def test_community_naming_empty(self):
"""Empty member list produces 'empty' name."""
name = _generate_community_name([])
assert name == "empty"
def test_cohesion_computation(self):
"""Cohesion is correctly computed as internal/(internal+external)."""
member_qns = {"a", "b"}
edges = [
GraphEdge(
id=1, kind="CALLS", source_qualified="a",
target_qualified="b", file_path="f.py", line=1, extra={},
),
GraphEdge(
id=2, kind="CALLS", source_qualified="a",
target_qualified="c", file_path="f.py", line=2, extra={},
),
]
cohesion = _compute_cohesion(member_qns, edges)
# 1 internal (a->b), 1 external (a->c) => 0.5
assert cohesion == pytest.approx(0.5)
def test_cohesion_all_internal(self):
"""All edges internal => cohesion = 1.0."""
member_qns = {"a", "b"}
edges = [
GraphEdge(
id=1, kind="CALLS", source_qualified="a",
target_qualified="b", file_path="f.py", line=1, extra={},
),
]
cohesion = _compute_cohesion(member_qns, edges)
assert cohesion == pytest.approx(1.0)
def test_cohesion_no_edges(self):
"""No edges => cohesion = 0.0."""
member_qns = {"a", "b"}
cohesion = _compute_cohesion(member_qns, [])
assert cohesion == pytest.approx(0.0)
def test_compute_cohesion_batch_matches_single(self):
"""Batch cohesion must produce identical results to calling
_compute_cohesion once per community. Regression guard for the
O(files * edges) -> O(edges) refactor.
"""
edges = [
# Internal to comm_a
GraphEdge(
id=1, kind="CALLS", source_qualified="a::f1",
target_qualified="a::f2", file_path="a.py", line=1, extra={},
),
# Cross-community (a <-> b): external to both
GraphEdge(
id=2, kind="CALLS", source_qualified="a::f1",
target_qualified="b::g1", file_path="a.py", line=2, extra={},
),
# Internal to comm_b
GraphEdge(
id=3, kind="CALLS", source_qualified="b::g1",
target_qualified="b::g2", file_path="b.py", line=3, extra={},
),
# Half-in (b -> c): external to b, ignored by a
GraphEdge(
id=4, kind="CALLS", source_qualified="b::g1",
target_qualified="c::h1", file_path="b.py", line=4, extra={},
),
# Neither endpoint in any tracked community — fully ignored
GraphEdge(
id=5, kind="CALLS", source_qualified="c::h1",
target_qualified="d::k1", file_path="c.py", line=5, extra={},
),
]
comm_a = {"a::f1", "a::f2"}
comm_b = {"b::g1", "b::g2"}
batch = _compute_cohesion_batch([comm_a, comm_b], edges)
expected = [
_compute_cohesion(comm_a, edges),
_compute_cohesion(comm_b, edges),
]
assert batch == expected
# Sanity: comm_a has 1 internal + 1 external = 0.5
# comm_b has 1 internal + 2 external = 1/3
assert batch[0] == pytest.approx(0.5)
assert batch[1] == pytest.approx(1 / 3)
def test_compute_cohesion_batch_empty(self):
"""Batch with empty list returns empty list."""
assert _compute_cohesion_batch([], []) == []
def test_compute_cohesion_batch_no_edges(self):
"""Batch with no edges returns 0.0 per community."""
result = _compute_cohesion_batch([{"a"}, {"b", "c"}], [])
assert result == [0.0, 0.0]
def test_detect_file_based_integration(self):
"""End-to-end: _detect_file_based produces correct member sets and
cohesion values on a hand-built fixture with asymmetric cohesions.
Guards the batch-cohesion refactor against zip misalignment, wrong
member_qns passed to the batch helper, and member/cohesion drift.
Cohesions are deliberately distinct (1.0 vs 0.6667) so a swap would
fail the assertions.
"""
def mk_node(nid: int, name: str, fp: str) -> GraphNode:
return GraphNode(
id=nid, kind="Function", name=name,
qualified_name=f"{fp}::{name}",
file_path=fp, line_start=1, line_end=10, language="python",
parent_name=None, params=None, return_type=None, is_test=False,
file_hash="h", extra={},
)
def mk_edge(eid: int, src: str, tgt: str, fp: str) -> GraphEdge:
return GraphEdge(
id=eid, kind="CALLS", source_qualified=src,
target_qualified=tgt, file_path=fp, line=1, extra={},
)
nodes = [
mk_node(1, "login", "auth.py"),
mk_node(2, "logout", "auth.py"),
mk_node(3, "check_token", "auth.py"),
mk_node(4, "connect", "db.py"),
mk_node(5, "query", "db.py"),
mk_node(6, "close", "db.py"),
]
edges = [
# auth.py: 2 internal, 0 external -> cohesion 1.0
mk_edge(1, "auth.py::login", "auth.py::check_token", "auth.py"),
mk_edge(2, "auth.py::logout", "auth.py::check_token", "auth.py"),
# db.py: 2 internal, 1 external -> cohesion 2/3 ≈ 0.6667
mk_edge(3, "db.py::query", "db.py::connect", "db.py"),
mk_edge(4, "db.py::close", "db.py::connect", "db.py"),
mk_edge(5, "db.py::close", "external.py::log", "db.py"),
]
result = _detect_file_based(nodes, edges, min_size=2)
assert len(result) == 2
by_desc = {c["description"]: c for c in result}
auth = by_desc["Directory-based community: auth"]
db = by_desc["Directory-based community: db"]
# Member sets — catches wrong member_qns being passed to batch helper
assert set(auth["members"]) == {
"auth.py::login", "auth.py::logout", "auth.py::check_token",
}
assert set(db["members"]) == {
"db.py::connect", "db.py::query", "db.py::close",
}
# Cohesions are distinct — zip misalignment would swap these
assert auth["cohesion"] == pytest.approx(1.0)
assert db["cohesion"] == pytest.approx(0.6667)
# Metadata passes through correctly
assert auth["size"] == 3
assert db["size"] == 3
assert auth["dominant_language"] == "python"
assert db["dominant_language"] == "python"
assert auth["level"] == 0
assert db["level"] == 0
def test_detected_cohesions_match_direct_computation(self):
"""Every stored community cohesion must equal what _compute_cohesion
produces when called directly on that community's member set and
the full edge list.
Algorithm-agnostic: runs against whichever path detect_communities
takes (Leiden if igraph is available, file-based otherwise). Any
regression in the batch-cohesion refactor that mis-aligns
cohesions to communities would fail loudly here with specific
community names.
The fixture is deliberately broken out of symmetry (one extra
internal edge in auth.py) so a swap between auth/db cohesions
would be visible.
"""
self._seed_two_clusters()
# Break cohesion symmetry: add one extra internal edge in auth.py
# so auth.py cohesion != db.py cohesion. Without this, the seeded
# fixture has both communities at 2/3 and a zip misalignment
# would be silent.
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="auth.py::login",
target="auth.py::logout", file_path="auth.py", line=12,
))
self.store.commit()
communities = detect_communities(self.store, min_size=2)
assert len(communities) > 0
all_edges = self.store.get_all_edges()
# Collect the distinct cohesion values we see, to guard against
# the degenerate case where the fixture somehow produces all-equal
# cohesions (which would make a swap undetectable).
seen_cohesions: set[float] = set()
for comm in communities:
# Sub-communities (level=1) have cohesion computed against
# a filtered sub-edge set, so skip them. The fixture is tiny
# enough that no sub-communities are produced in practice.
if comm.get("level", 0) != 0:
continue
member_qns = set(comm["members"])
direct = round(_compute_cohesion(member_qns, all_edges), 4)
assert comm["cohesion"] == direct, (
f"Community {comm['name']!r} stored cohesion "
f"{comm['cohesion']} but direct computation gives {direct}"
)
seen_cohesions.add(comm["cohesion"])
# Sanity: the fixture produced communities with distinct cohesions,
# so the equality check above actually guards against swaps.
assert len(seen_cohesions) >= 2, (
"Fixture regression: all detected communities have the same "
"cohesion, which means a zip misalignment bug would not be "
f"caught here. seen={seen_cohesions}"
)
def test_get_communities_sort_by(self):
"""get_communities respects sort_by parameter."""
self._seed_two_clusters()
communities = detect_communities(self.store, min_size=2)
store_communities(self.store, communities)
by_size = get_communities(self.store, sort_by="size")
assert len(by_size) > 0
# Sizes should be in descending order
sizes = [c["size"] for c in by_size]
assert sizes == sorted(sizes, reverse=True)
by_name = get_communities(self.store, sort_by="name")
names = [c["name"] for c in by_name]
assert names == sorted(names)
def test_get_communities_min_size_filter(self):
"""get_communities with min_size filters small communities."""
self._seed_two_clusters()
communities = detect_communities(self.store, min_size=1)
store_communities(self.store, communities)
# With very high min_size, should get empty
result = get_communities(self.store, min_size=999)
assert len(result) == 0
def test_store_communities_clears_previous(self):
"""Storing communities clears previous community data."""
self._seed_two_clusters()
communities = detect_communities(self.store, min_size=2)
store_communities(self.store, communities)
first_count = len(get_communities(self.store))
assert first_count > 0
# Store again with empty list
store_communities(self.store, [])
assert len(get_communities(self.store)) == 0
def test_detect_communities_empty_graph(self):
"""Detect on empty graph returns empty list."""
result = detect_communities(self.store, min_size=2)
assert result == []
def test_igraph_available_is_bool(self):
"""IGRAPH_AVAILABLE is a boolean."""
assert isinstance(IGRAPH_AVAILABLE, bool)
def test_leiden_fallback_to_file_based(self):
"""When Leiden produces 0 communities (all < min_size), fall back to file-based."""
# Seed nodes with only CONTAINS edges (no CALLS/IMPORTS -- sparse graph)
self.store.upsert_node(
NodeInfo(
kind="File", name="a.py", file_path="a.py",
line_start=1, line_end=100, language="python",
), file_hash="a1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="f1", file_path="a.py",
line_start=1, line_end=10, language="python",
parent_name=None,
), file_hash="a1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="f2", file_path="a.py",
line_start=11, line_end=20, language="python",
parent_name=None,
), file_hash="a1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="f3", file_path="a.py",
line_start=21, line_end=30, language="python",
parent_name=None,
), file_hash="a1"
)
self.store.upsert_edge(
EdgeInfo(kind="CONTAINS", source="a.py", target="a.py::f1",
file_path="a.py", line=1)
)
self.store.upsert_edge(
EdgeInfo(kind="CONTAINS", source="a.py", target="a.py::f2",
file_path="a.py", line=11)
)
self.store.upsert_edge(
EdgeInfo(kind="CONTAINS", source="a.py", target="a.py::f3",
file_path="a.py", line=21)
)
# With high min_size, Leiden may produce tiny clusters that get dropped.
# The fallback to file-based should still produce results.
result = detect_communities(self.store, min_size=2)
assert isinstance(result, list)
assert len(result) >= 1
def test_incremental_detect_no_affected_communities(self):
"""incremental_detect_communities returns 0 when no communities are affected."""
self._seed_two_clusters()
communities = detect_communities(self.store, min_size=2)
store_communities(self.store, communities)
# Pass a file that has no nodes in any community
result = incremental_detect_communities(self.store, ["nonexistent.py"])
assert result == 0
def test_incremental_detect_redetects_affected(self):
"""incremental_detect_communities re-detects when communities ARE affected."""
self._seed_two_clusters()
communities = detect_communities(self.store, min_size=2)
stored = store_communities(self.store, communities)
assert stored > 0
# Pass a file that IS part of existing communities
result = incremental_detect_communities(self.store, ["auth.py"])
assert result > 0
+64
View File
@@ -0,0 +1,64 @@
"""Tests for compact estimated context savings metadata."""
from __future__ import annotations
import json
from code_review_graph.context_savings import (
estimate_context_savings,
estimate_file_tokens,
estimate_tokens,
format_context_savings,
)
def test_estimate_tokens_uses_conservative_character_approximation():
assert estimate_tokens("") == 0
assert estimate_tokens("abcd") == 1
assert estimate_tokens("abcde") == 2
def test_estimate_context_savings_returns_tiny_metadata():
estimate = estimate_context_savings(
original_tokens=100,
returned_context="x" * 80,
)
assert estimate == {
"estimated": True,
"saved_tokens": 80,
"saved_percent": 80,
}
assert len(json.dumps(estimate, separators=(",", ":"))) < 64
def test_estimate_context_savings_never_reports_negative_savings():
estimate = estimate_context_savings(
original_tokens=10,
returned_context="x" * 200,
)
assert estimate == {
"estimated": True,
"saved_tokens": 0,
"saved_percent": 0,
}
def test_estimate_context_savings_unknown_original_returns_none():
assert estimate_context_savings(original_tokens=0, returned_context="x") is None
def test_estimate_file_tokens_uses_file_sizes_without_reading_contents(tmp_path):
source = tmp_path / "source.py"
source.write_text("x" * 17, encoding="utf-8")
assert estimate_file_tokens(tmp_path, ["source.py", "missing.py"]) == 5
def test_format_context_savings_is_one_short_line():
text = format_context_savings(
{"estimated": True, "saved_tokens": 1240, "saved_percent": 18}
)
assert text == "Estimated context saved: ~1,240 tokens (~18%)"
+306
View File
@@ -0,0 +1,306 @@
"""Tests for config-driven custom language support (languages.toml, #320).
Erlang is used as the end-to-end grammar: tree_sitter_language_pack ships
it, but code-review-graph has no built-in ``.erl`` support (only Elixir on
the BEAM side), so it exercises the full bring-your-own-language path.
"""
import logging
from pathlib import Path
import pytest
from code_review_graph import custom_languages
from code_review_graph.custom_languages import (
CONFIG_RELATIVE_PATH,
MAX_CUSTOM_LANGUAGES,
load_custom_languages,
)
from code_review_graph.parser import (
EXTENSION_TO_LANGUAGE,
CodeParser,
_builtin_language_names,
)
BUILTIN_LANGUAGES = _builtin_language_names()
ERLANG_TOML = """\
[languages.erlang]
extensions = [".erl"]
grammar = "erlang"
function_node_types = ["function_clause"]
class_node_types = ["record_decl"]
import_node_types = ["import_attribute"]
call_node_types = ["call"]
comment = "Erlang via the bundled tree-sitter-erlang grammar"
"""
ERLANG_SOURCE = """\
-module(math_utils).
-export([add/2, scale/2]).
-import(lists, [map/2]).
-record(point, {x, y}).
add(A, B) ->
helper(A) + B.
helper(X) -> X * 2.
scale(Points, F) ->
lists:map(fun(P) -> add(P, F) end, Points).
"""
def write_config(repo_root: Path, text: str) -> Path:
config_path = repo_root / CONFIG_RELATIVE_PATH
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(text, encoding="utf-8")
return config_path
def load(repo_root: Path):
return load_custom_languages(
repo_root,
builtin_extensions=EXTENSION_TO_LANGUAGE,
builtin_languages=BUILTIN_LANGUAGES,
)
@pytest.fixture(autouse=True)
def _clear_loader_cache():
custom_languages.clear_cache()
yield
custom_languages.clear_cache()
class TestLoader:
def test_missing_file_returns_empty(self, tmp_path):
assert load(tmp_path) == {}
def test_malformed_toml_warns_and_returns_empty(self, tmp_path, caplog):
write_config(tmp_path, "[languages.broken\nnot toml at all")
with caplog.at_level(logging.WARNING):
assert load(tmp_path) == {}
assert "Malformed TOML" in caplog.text
def test_valid_language_loaded(self, tmp_path):
write_config(tmp_path, ERLANG_TOML)
result = load(tmp_path)
assert set(result) == {"erlang"}
lang = result["erlang"]
assert lang.grammar == "erlang"
assert lang.extensions == (".erl",)
assert lang.function_node_types == ("function_clause",)
assert lang.class_node_types == ("record_decl",)
assert lang.import_node_types == ("import_attribute",)
assert lang.call_node_types == ("call",)
assert "tree-sitter-erlang" in lang.comment
def test_extensions_normalised_to_lowercase(self, tmp_path):
write_config(tmp_path, """\
[languages.erlang]
extensions = [".ERL"]
grammar = "erlang"
function_node_types = ["function_clause"]
""")
result = load(tmp_path)
assert result["erlang"].extensions == (".erl",)
def test_bad_grammar_skipped(self, tmp_path, caplog):
write_config(tmp_path, """\
[languages.mylang]
extensions = [".myl"]
grammar = "not_a_real_grammar"
function_node_types = ["function_definition"]
""")
with caplog.at_level(logging.WARNING):
assert load(tmp_path) == {}
assert "not_a_real_grammar" in caplog.text
assert "tree_sitter_language_pack" in caplog.text
def test_builtin_extension_collision_skipped(self, tmp_path, caplog):
write_config(tmp_path, """\
[languages.notpython]
extensions = [".py"]
grammar = "erlang"
function_node_types = ["function_clause"]
""")
with caplog.at_level(logging.WARNING):
assert load(tmp_path) == {}
assert "built-in" in caplog.text
def test_extension_without_dot_skipped(self, tmp_path, caplog):
write_config(tmp_path, """\
[languages.erlang]
extensions = ["erl"]
grammar = "erlang"
function_node_types = ["function_clause"]
""")
with caplog.at_level(logging.WARNING):
assert load(tmp_path) == {}
assert "must start with a dot" in caplog.text
def test_builtin_language_name_shadowing_skipped(self, tmp_path, caplog):
write_config(tmp_path, """\
[languages.python]
extensions = [".pyq"]
grammar = "python"
function_node_types = ["function_definition"]
""")
with caplog.at_level(logging.WARNING):
assert load(tmp_path) == {}
assert "shadows a built-in language" in caplog.text
def test_duplicate_extension_across_custom_languages_skipped(
self, tmp_path, caplog,
):
write_config(tmp_path, """\
[languages.first]
extensions = [".dup"]
grammar = "erlang"
function_node_types = ["function_clause"]
[languages.second]
extensions = [".dup"]
grammar = "erlang"
function_node_types = ["function_clause"]
""")
with caplog.at_level(logging.WARNING):
result = load(tmp_path)
assert set(result) == {"first"}
assert "already claimed" in caplog.text
def test_missing_grammar_key_skipped(self, tmp_path, caplog):
write_config(tmp_path, """\
[languages.nogramma]
extensions = [".ng"]
function_node_types = ["function_definition"]
""")
with caplog.at_level(logging.WARNING):
assert load(tmp_path) == {}
assert "grammar" in caplog.text
def test_no_node_types_skipped(self, tmp_path, caplog):
write_config(tmp_path, """\
[languages.empty]
extensions = [".emp"]
grammar = "erlang"
""")
with caplog.at_level(logging.WARNING):
assert load(tmp_path) == {}
assert "no node types" in caplog.text
def test_invalid_node_type_list_skipped(self, tmp_path, caplog):
write_config(tmp_path, """\
[languages.badtypes]
extensions = [".bt"]
grammar = "erlang"
function_node_types = "function_clause"
""")
with caplog.at_level(logging.WARNING):
assert load(tmp_path) == {}
assert "list of non-empty strings" in caplog.text
def test_cap_at_max_custom_languages(self, tmp_path, caplog):
blocks = [
f"""\
[languages.lang{i:02d}]
extensions = [".l{i:02d}"]
grammar = "erlang"
function_node_types = ["function_clause"]
"""
for i in range(MAX_CUSTOM_LANGUAGES + 2)
]
write_config(tmp_path, "\n".join(blocks))
with caplog.at_level(logging.WARNING):
result = load(tmp_path)
assert len(result) == MAX_CUSTOM_LANGUAGES
assert "ignoring the rest" in caplog.text
def test_cache_reused_and_isolated(self, tmp_path):
write_config(tmp_path, ERLANG_TOML)
first = load(tmp_path)
first.pop("erlang") # Mutating the returned dict must not poison the cache
second = load(tmp_path)
assert set(second) == {"erlang"}
class TestParserIntegration:
def _repo(self, tmp_path: Path) -> tuple[Path, Path]:
write_config(tmp_path, ERLANG_TOML)
src = tmp_path / "src" / "math_utils.erl"
src.parent.mkdir(parents=True)
src.write_text(ERLANG_SOURCE, encoding="utf-8")
return tmp_path, src
def test_detect_language_with_and_without_config(self, tmp_path):
repo, src = self._repo(tmp_path)
assert CodeParser(repo).detect_language(src) == "erlang"
# Without repo_root the custom extension stays unknown.
assert CodeParser().detect_language(src) is None
def test_builtin_extensions_unaffected(self, tmp_path):
repo, _src = self._repo(tmp_path)
parser = CodeParser(repo)
assert parser.detect_language(Path("main.py")) == "python"
assert parser.detect_language(Path("app.ex")) == "elixir"
def test_e2e_nodes_and_edges(self, tmp_path):
repo, src = self._repo(tmp_path)
parser = CodeParser(repo)
nodes, edges = parser.parse_file(src)
files = [n for n in nodes if n.kind == "File"]
assert len(files) == 1
assert files[0].language == "erlang"
funcs = {n.name: n for n in nodes if n.kind == "Function"}
assert {"add", "helper", "scale"} <= set(funcs)
assert funcs["add"].language == "erlang"
assert funcs["add"].line_start == 7
classes = {n.name: n for n in nodes if n.kind == "Class"}
assert "point" in classes
assert classes["point"].language == "erlang"
file_path = str(src)
calls = {(e.source, e.target) for e in edges if e.kind == "CALLS"}
# helper(A) inside add/2 resolves to the same-file definition.
assert (f"{file_path}::add", f"{file_path}::helper") in calls
# add(P, F) inside the anonymous fun passed to lists:map.
assert (f"{file_path}::scale", f"{file_path}::add") in calls
# Remote call keeps its qualified module:function form.
assert (f"{file_path}::scale", "lists:map") in calls
imports = {e.target for e in edges if e.kind == "IMPORTS_FROM"}
assert "lists" in imports
contains = {e.target for e in edges if e.kind == "CONTAINS"}
assert f"{file_path}::add" in contains
assert f"{file_path}::point" in contains
def test_e2e_parse_without_config_yields_nothing(self, tmp_path):
_repo, src = self._repo(tmp_path)
nodes, edges = CodeParser().parse_file(src)
assert nodes == []
assert edges == []
def test_full_build_includes_custom_language(self, tmp_path):
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import full_build
repo, src = self._repo(tmp_path)
db_path = repo / ".code-review-graph" / "graph.db"
store = GraphStore(db_path)
try:
stats = full_build(repo, store)
assert stats["files_parsed"] >= 1
row = store._conn.execute(
"SELECT language FROM nodes WHERE kind = 'Function' AND name = ?",
("helper",),
).fetchone()
assert row is not None
assert row[0] == "erlang"
finally:
store.close()
+1126
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
"""Tests for the PreToolUse search enrichment module."""
import tempfile
from pathlib import Path
from code_review_graph.enrich import (
enrich_file_read,
enrich_search,
extract_pattern,
)
from code_review_graph.graph import GraphStore
from code_review_graph.parser import EdgeInfo, NodeInfo
from code_review_graph.search import rebuild_fts_index
class TestExtractPattern:
def test_grep_pattern(self):
assert extract_pattern("Grep", {"pattern": "parse_file"}) == "parse_file"
def test_grep_empty(self):
assert extract_pattern("Grep", {}) is None
def test_glob_meaningful_name(self):
assert extract_pattern("Glob", {"pattern": "**/auth*.ts"}) == "auth"
def test_glob_pure_extension(self):
assert extract_pattern("Glob", {"pattern": "**/*.ts"}) is None
def test_glob_short_name(self):
# "ab" is only 2 chars, below minimum regex match of 3
assert extract_pattern("Glob", {"pattern": "**/ab.ts"}) is None
def test_bash_rg_pattern(self):
result = extract_pattern("Bash", {"command": "rg parse_file src/"})
assert result == "parse_file"
def test_bash_grep_pattern(self):
result = extract_pattern("Bash", {"command": "grep -r 'GraphStore' ."})
assert result == "GraphStore"
def test_bash_rg_with_flags(self):
result = extract_pattern("Bash", {"command": "rg -t py -i parse_file"})
assert result == "parse_file"
def test_bash_non_grep_command(self):
assert extract_pattern("Bash", {"command": "ls -la"}) is None
def test_bash_short_pattern(self):
# Pattern "ab" is only 2 chars
assert extract_pattern("Bash", {"command": "rg ab src/"}) is None
def test_unknown_tool(self):
assert extract_pattern("Write", {"content": "hello"}) is None
def test_bash_rg_with_glob_flag(self):
result = extract_pattern(
"Bash", {"command": "rg --glob '*.py' parse_file"}
)
assert result == "parse_file"
class TestEnrichSearch:
def setup_method(self):
self.tmpdir = tempfile.mkdtemp()
self.db_dir = Path(self.tmpdir) / ".code-review-graph"
self.db_dir.mkdir()
self.db_path = self.db_dir / "graph.db"
self.store = GraphStore(self.db_path)
self._seed_data()
def teardown_method(self):
self.store.close()
def _seed_data(self):
nodes = [
NodeInfo(
kind="Function", name="parse_file", file_path=f"{self.tmpdir}/parser.py",
line_start=10, line_end=50, language="python",
params="(path: str)", return_type="list[Node]",
),
NodeInfo(
kind="Function", name="full_build", file_path=f"{self.tmpdir}/build.py",
line_start=1, line_end=30, language="python",
),
NodeInfo(
kind="Test", name="test_parse_file",
file_path=f"{self.tmpdir}/test_parser.py",
line_start=1, line_end=20, language="python",
is_test=True,
),
]
for n in nodes:
self.store.upsert_node(n)
edges = [
EdgeInfo(
kind="CALLS",
source=f"{self.tmpdir}/build.py::full_build",
target=f"{self.tmpdir}/parser.py::parse_file",
file_path=f"{self.tmpdir}/build.py", line=15,
),
EdgeInfo(
kind="TESTED_BY",
source=f"{self.tmpdir}/test_parser.py::test_parse_file",
target=f"{self.tmpdir}/parser.py::parse_file",
file_path=f"{self.tmpdir}/test_parser.py", line=1,
),
]
for e in edges:
self.store.upsert_edge(e)
rebuild_fts_index(self.store)
def test_returns_matching_symbols(self):
result = enrich_search("parse_file", self.tmpdir)
assert "[code-review-graph]" in result
assert "parse_file" in result
def test_includes_callers(self):
result = enrich_search("parse_file", self.tmpdir)
assert "Called by:" in result
assert "full_build" in result
def test_includes_tests(self):
result = enrich_search("parse_file", self.tmpdir)
assert "Tests:" in result
assert "test_parse_file" in result
def test_excludes_test_nodes(self):
result = enrich_search("test_parse", self.tmpdir)
# test nodes should be filtered out of results
assert "test_parse_file" not in result or "symbol(s)" in result
def test_empty_for_no_match(self):
result = enrich_search("nonexistent_function_xyz", self.tmpdir)
assert result == ""
def test_empty_for_missing_db(self):
result = enrich_search("parse_file", "/tmp/nonexistent_repo_xyz")
assert result == ""
class TestEnrichFileRead:
def setup_method(self):
self.tmpdir = tempfile.mkdtemp()
self.db_dir = Path(self.tmpdir) / ".code-review-graph"
self.db_dir.mkdir()
self.db_path = self.db_dir / "graph.db"
self.store = GraphStore(self.db_path)
self._seed_data()
def teardown_method(self):
self.store.close()
def _seed_data(self):
self.file_path = f"{self.tmpdir}/parser.py"
nodes = [
NodeInfo(
kind="File", name="parser.py", file_path=self.file_path,
line_start=1, line_end=100, language="python",
),
NodeInfo(
kind="Function", name="parse_file", file_path=self.file_path,
line_start=10, line_end=50, language="python",
),
NodeInfo(
kind="Function", name="parse_imports", file_path=self.file_path,
line_start=55, line_end=80, language="python",
),
]
for n in nodes:
self.store.upsert_node(n)
edges = [
EdgeInfo(
kind="CALLS",
source=f"{self.file_path}::parse_file",
target=f"{self.file_path}::parse_imports",
file_path=self.file_path, line=30,
),
]
for e in edges:
self.store.upsert_edge(e)
self.store._conn.commit()
def test_returns_file_symbols(self):
result = enrich_file_read(self.file_path, self.tmpdir)
assert "[code-review-graph]" in result
assert "parse_file" in result
assert "parse_imports" in result
def test_excludes_file_nodes(self):
result = enrich_file_read(self.file_path, self.tmpdir)
# File node "parser.py" should not appear as a symbol entry
lines = result.split("\n")
symbol_lines = [
ln for ln in lines
if ln and not ln.startswith(" ") and not ln.startswith("[")
]
for line in symbol_lines:
assert "parser.py (" not in line or "parse_" in line
def test_includes_callees(self):
result = enrich_file_read(self.file_path, self.tmpdir)
assert "Calls:" in result
assert "parse_imports" in result
def test_empty_for_unknown_file(self):
result = enrich_file_read("/nonexistent/file.py", self.tmpdir)
assert result == ""
def test_empty_for_missing_db(self):
result = enrich_file_read(self.file_path, "/tmp/nonexistent_repo_xyz")
assert result == ""
class TestRunHookOutput:
"""Test the JSON output format of run_hook via enrich_search."""
def test_hook_json_format(self):
"""Verify the hookSpecificOutput structure is correct."""
# We test the format indirectly by checking enrich_search output
# since run_hook reads from stdin which is harder to test
tmpdir = tempfile.mkdtemp()
db_dir = Path(tmpdir) / ".code-review-graph"
db_dir.mkdir()
store = GraphStore(db_dir / "graph.db")
store.upsert_node(
NodeInfo(
kind="Function", name="my_function",
file_path=f"{tmpdir}/mod.py",
line_start=1, line_end=10, language="python",
),
)
rebuild_fts_index(store)
store.close()
result = enrich_search("my_function", tmpdir)
assert result.startswith("[code-review-graph]")
assert "my_function" in result
+928
View File
@@ -0,0 +1,928 @@
"""Tests for the evaluation framework (scorer, reporter, runner, benchmarks)."""
import csv
import os
import subprocess
import tempfile
from pathlib import Path
import pytest
from code_review_graph.eval.reporter import (
generate_full_report,
generate_markdown_report,
generate_readme_tables,
)
try:
import yaml as _yaml # noqa: F401
from code_review_graph.eval.runner import write_csv
_HAS_YAML = True
except ImportError:
_HAS_YAML = False
write_csv = None # type: ignore[assignment]
from code_review_graph.eval.scorer import (
compute_mrr,
compute_precision_recall,
compute_token_efficiency,
)
# --- Existing scorer tests ---
def test_token_efficiency():
result = compute_token_efficiency(10000, 3000)
assert result["raw_tokens"] == 10000
assert result["graph_tokens"] == 3000
assert result["ratio"] == 0.3
assert result["reduction_percent"] == 70.0
def test_token_efficiency_zero_raw():
result = compute_token_efficiency(0, 100)
assert result["ratio"] == 0.0
assert result["reduction_percent"] == 0.0
def test_mrr_found_at_rank_2():
result = compute_mrr("b", ["a", "b", "c"])
assert result == 0.5
def test_mrr_found_at_rank_1():
result = compute_mrr("a", ["a", "b", "c"])
assert result == 1.0
def test_mrr_not_found():
result = compute_mrr("z", ["a", "b", "c"])
assert result == 0.0
def test_precision_recall():
predicted = {"a", "b", "c", "d"}
actual = {"b", "c", "e"}
result = compute_precision_recall(predicted, actual)
assert result["precision"] == 0.5
assert result["recall"] == round(2 / 3, 4)
expected_f1 = round(2 * 0.5 * (2 / 3) / (0.5 + 2 / 3), 4)
assert result["f1"] == expected_f1
def test_precision_recall_empty_sets():
result = compute_precision_recall(set(), set())
assert result["precision"] == 1.0
assert result["recall"] == 1.0
assert result["f1"] == 1.0
def test_precision_recall_no_overlap():
result = compute_precision_recall({"a"}, {"b"})
assert result["precision"] == 0.0
assert result["recall"] == 0.0
assert result["f1"] == 0.0
def test_generate_markdown_report():
results = [
{
"benchmark": "token_efficiency",
"ratio": 0.3,
"reduction_percent": 70.0,
},
{
"benchmark": "search_mrr",
"ratio": "-",
"reduction_percent": "-",
},
]
report = generate_markdown_report(results)
assert "# Evaluation Report" in report
assert "## Summary" in report
assert "token_efficiency" in report
assert "search_mrr" in report
assert "70.0" in report
assert "| Benchmark |" in report
def test_generate_markdown_report_empty():
report = generate_markdown_report([])
assert "No benchmark results" in report
# --- New tests ---
@pytest.mark.skipif(not _HAS_YAML, reason="pyyaml not installed")
def test_load_config():
"""Load a temp YAML config and verify structure."""
import yaml
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False
) as f:
yaml.dump(
{
"name": "test-repo",
"url": "https://example.com/repo.git",
"commit": "HEAD",
"language": "python",
"size_category": "small",
"test_commits": [{"sha": "abc123", "description": "test"}],
"entry_points": ["main.py::main"],
"search_queries": [
{"query": "hello", "expected": "main.py::greet"}
],
},
f,
)
tmp_path = f.name
try:
import yaml as _yaml
with open(tmp_path) as fh:
config = _yaml.safe_load(fh)
assert config["name"] == "test-repo"
assert config["language"] == "python"
assert len(config["test_commits"]) == 1
assert len(config["entry_points"]) == 1
assert len(config["search_queries"]) == 1
finally:
os.unlink(tmp_path)
@pytest.mark.skipif(not _HAS_YAML, reason="pyyaml not installed")
def test_write_csv():
"""Write results to CSV and read back."""
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "results" / "test.csv"
results = [
{"repo": "foo", "tokens": 100, "ratio": 2.5},
{"repo": "bar", "tokens": 200, "ratio": 1.5},
]
write_csv(results, path)
assert path.exists()
with open(path, newline="") as f:
reader = csv.DictReader(f)
rows = list(reader)
assert len(rows) == 2
assert rows[0]["repo"] == "foo"
assert rows[1]["tokens"] == "200"
@pytest.mark.skipif(not _HAS_YAML, reason="pyyaml not installed")
def test_write_csv_empty():
"""Writing empty results should be a no-op."""
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "empty.csv"
write_csv([], path)
assert not path.exists()
def test_generate_readme_tables():
"""Feed sample CSV data and verify table format."""
with tempfile.TemporaryDirectory() as tmpdir:
results_dir = Path(tmpdir)
# Write token efficiency CSV
te_path = results_dir / "test_token_efficiency_2026-01-01.csv"
with open(te_path, "w", newline="") as f:
w = csv.DictWriter(
f,
fieldnames=[
"repo", "commit", "description", "changed_files",
"naive_tokens", "standard_tokens", "graph_tokens",
"naive_to_graph_ratio", "standard_to_graph_ratio",
],
)
w.writeheader()
w.writerow({
"repo": "myrepo", "commit": "abc", "description": "test",
"changed_files": "3", "naive_tokens": "1000",
"standard_tokens": "500", "graph_tokens": "200",
"naive_to_graph_ratio": "5.0",
"standard_to_graph_ratio": "2.5",
})
tables = generate_readme_tables(results_dir)
assert "### Token Efficiency" in tables
assert "myrepo" in tables
assert "1000" in tables
def test_generate_full_report():
"""Feed sample CSV data and verify report sections."""
with tempfile.TemporaryDirectory() as tmpdir:
results_dir = Path(tmpdir)
# Write a build_performance CSV
bp_path = results_dir / "test_build_performance_2026-01-01.csv"
with open(bp_path, "w", newline="") as f:
w = csv.DictWriter(
f,
fieldnames=[
"repo", "file_count", "node_count", "edge_count",
"flow_detection_seconds", "community_detection_seconds",
"search_avg_ms", "nodes_per_second",
],
)
w.writeheader()
w.writerow({
"repo": "testrepo", "file_count": "10", "node_count": "50",
"edge_count": "30", "flow_detection_seconds": "0.1",
"community_detection_seconds": "0.2",
"search_avg_ms": "5.0", "nodes_per_second": "500",
})
report = generate_full_report(results_dir)
assert "# Evaluation Report" in report
assert "## Methodology" in report
assert "## Build Performance" in report
assert "testrepo" in report
@pytest.mark.skipif(not _HAS_YAML, reason="pyyaml not installed")
def test_runner_with_mock_repo():
"""Create a tiny git repo with 2 Python files, run benchmarks, verify output."""
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = Path(tmpdir) / "mock_repo"
repo_path.mkdir()
# Init git repo
subprocess.run(
["git", "init"], cwd=str(repo_path), capture_output=True
)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=str(repo_path), capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=str(repo_path), capture_output=True,
)
# Create two Python files
(repo_path / "main.py").write_text(
'from helper import greet\n\ndef main():\n greet("world")\n',
encoding="utf-8",
)
(repo_path / "helper.py").write_text(
'def greet(name):\n print(f"Hello {name}")\n',
encoding="utf-8",
)
subprocess.run(
["git", "add", "."], cwd=str(repo_path), capture_output=True
)
subprocess.run(
["git", "commit", "-m", "initial"],
cwd=str(repo_path), capture_output=True,
)
# Second commit: modify helper.py
(repo_path / "helper.py").write_text(
'def greet(name):\n print(f"Hi {name}!")\n',
encoding="utf-8",
)
subprocess.run(
["git", "add", "."], cwd=str(repo_path), capture_output=True
)
subprocess.run(
["git", "commit", "-m", "update greeting"],
cwd=str(repo_path), capture_output=True,
)
# Build graph
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import full_build, get_db_path
db_path = get_db_path(repo_path)
store = GraphStore(db_path)
full_build(repo_path, store)
config = {
"name": "mock",
"language": "python",
"test_commits": [
{"sha": "HEAD", "description": "update greeting"},
],
"entry_points": ["main.py::main"],
"search_queries": [
{"query": "greet", "expected": "helper.py::greet"},
],
}
# Run token_efficiency
from code_review_graph.eval.benchmarks import token_efficiency
te_results = token_efficiency.run(repo_path, store, config)
assert len(te_results) >= 1
assert "naive_tokens" in te_results[0]
assert "graph_tokens" in te_results[0]
# Run impact_accuracy
from code_review_graph.eval.benchmarks import impact_accuracy
ia_results = impact_accuracy.run(repo_path, store, config)
assert len(ia_results) >= 1
assert "precision" in ia_results[0]
assert "f1" in ia_results[0]
# Run search_quality
from code_review_graph.eval.benchmarks import search_quality
sq_results = search_quality.run(repo_path, store, config)
assert len(sq_results) == 1
assert "reciprocal_rank" in sq_results[0]
# Run build_performance
from code_review_graph.eval.benchmarks import build_performance
bp_results = build_performance.run(repo_path, store, config)
assert len(bp_results) == 1
assert "node_count" in bp_results[0]
assert bp_results[0]["node_count"] > 0
store.close()
# --- Token benchmark tests ---
def test_estimate_tokens_basic():
"""estimate_tokens should return a reasonable approximation."""
from code_review_graph.eval.token_benchmark import estimate_tokens
# Simple string: "hello" => JSON '"hello"' (7 chars) => 7 // 4 = 1
assert estimate_tokens("hello") == 1
# Dict: {"a": 1} => '{"a": 1}' (8 chars) => 8 // 4 = 2
assert estimate_tokens({"a": 1}) == 2
# Longer content should scale proportionally
long_text = "x" * 400
tokens = estimate_tokens(long_text)
# JSON adds 2 quote chars: (400 + 2) // 4 = 100
assert tokens == 100
def test_estimate_tokens_nested():
"""estimate_tokens handles nested structures."""
from code_review_graph.eval.token_benchmark import estimate_tokens
nested = {"nodes": [{"name": "foo"}, {"name": "bar"}], "count": 2}
tokens = estimate_tokens(nested)
assert tokens > 0
assert isinstance(tokens, int)
def test_estimate_tokens_non_serializable():
"""estimate_tokens uses default=str for non-serializable objects."""
from pathlib import Path
from code_review_graph.eval.token_benchmark import estimate_tokens
# Path objects are not JSON-serializable but default=str handles them
tokens = estimate_tokens({"path": Path("/tmp/test")})
assert tokens > 0
def test_benchmark_review_workflow():
"""benchmark_review_workflow completes and returns expected structure."""
from code_review_graph.eval.token_benchmark import benchmark_review_workflow
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = Path(tmpdir) / "bench_repo"
repo_path.mkdir()
# Init git repo with two commits
subprocess.run(
["git", "init"], cwd=str(repo_path), capture_output=True,
)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=str(repo_path), capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=str(repo_path), capture_output=True,
)
(repo_path / "main.py").write_text(
'from helper import greet\n\ndef main():\n greet("world")\n',
encoding="utf-8",
)
(repo_path / "helper.py").write_text(
'def greet(name):\n print(f"Hello {name}")\n',
encoding="utf-8",
)
subprocess.run(
["git", "add", "."], cwd=str(repo_path), capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "initial"],
cwd=str(repo_path), capture_output=True,
)
# Second commit
(repo_path / "helper.py").write_text(
'def greet(name):\n print(f"Hi {name}!")\n',
encoding="utf-8",
)
subprocess.run(
["git", "add", "."], cwd=str(repo_path), capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "update greeting"],
cwd=str(repo_path), capture_output=True,
)
# Build graph
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import full_build, get_db_path
db_path = get_db_path(repo_path)
store = GraphStore(db_path)
full_build(repo_path, store)
store.close()
# Run the review benchmark
result = benchmark_review_workflow(
repo_root=str(repo_path), base="HEAD~1",
)
assert result["workflow"] == "review"
assert result["total_tokens"] > 0
assert result["tool_calls"] == 2
assert len(result["calls"]) == 2
assert result["calls"][0]["tool"] == "get_minimal_context"
assert result["calls"][1]["tool"] == "detect_changes_minimal"
for call in result["calls"]:
assert call["tokens"] >= 0
def test_run_all_benchmarks():
"""run_all_benchmarks returns results for all workflows."""
from code_review_graph.eval.token_benchmark import run_all_benchmarks
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = Path(tmpdir) / "all_bench_repo"
repo_path.mkdir()
subprocess.run(
["git", "init"], cwd=str(repo_path), capture_output=True,
)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=str(repo_path), capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=str(repo_path), capture_output=True,
)
(repo_path / "app.py").write_text(
'def main():\n print("hello")\n',
encoding="utf-8",
)
subprocess.run(
["git", "add", "."], cwd=str(repo_path), capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "initial"],
cwd=str(repo_path), capture_output=True,
)
(repo_path / "app.py").write_text(
'def main():\n print("hi")\n',
encoding="utf-8",
)
subprocess.run(
["git", "add", "."], cwd=str(repo_path), capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "update"],
cwd=str(repo_path), capture_output=True,
)
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import full_build, get_db_path
db_path = get_db_path(repo_path)
store = GraphStore(db_path)
full_build(repo_path, store)
store.close()
results = run_all_benchmarks(repo_root=str(repo_path), base="HEAD~1")
# Should have one result per workflow (5 total)
assert len(results) == 5
workflow_names = {r["workflow"] for r in results}
assert workflow_names == {
"review", "architecture", "debug", "onboard", "pre_merge",
}
# Each successful result should have total_tokens
for r in results:
if "error" not in r:
assert r["total_tokens"] >= 0
assert "calls" in r
# --- Failure-inflation regression tests + agent_baseline + co-change mode ---
def _git(repo_path, *args):
subprocess.run(["git", *args], cwd=str(repo_path), capture_output=True)
def _make_repo(tmpdir, two_file_commit=False):
"""Tiny git repo: initial commit, then a second commit touching 1 or 2 files."""
repo_path = Path(tmpdir) / "mock_repo"
repo_path.mkdir()
_git(repo_path, "init")
_git(repo_path, "config", "user.email", "test@test.com")
_git(repo_path, "config", "user.name", "Test")
(repo_path / "main.py").write_text(
'from helper import greet\n\ndef main():\n greet("world")\n',
encoding="utf-8",
)
(repo_path / "helper.py").write_text(
'def greet(name):\n print(f"Hello {name}")\n',
encoding="utf-8",
)
_git(repo_path, "add", ".")
_git(repo_path, "commit", "-m", "initial")
(repo_path / "helper.py").write_text(
'def greet(name):\n print(f"Hi {name}!")\n',
encoding="utf-8",
)
if two_file_commit:
(repo_path / "main.py").write_text(
'from helper import greet\n\ndef main():\n greet("there")\n',
encoding="utf-8",
)
_git(repo_path, "add", ".")
_git(repo_path, "commit", "-m", "update greeting")
return repo_path
def _build_store(repo_path):
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import full_build, get_db_path
store = GraphStore(get_db_path(repo_path))
full_build(repo_path, store)
return store
def _mock_config(**extra):
config = {
"name": "mock",
"language": "python",
"test_commits": [{"sha": "HEAD", "description": "update greeting"}],
"entry_points": ["main.py::main"],
"search_queries": [{"query": "greet", "expected": "helper.py::greet"}],
}
config.update(extra)
return config
def test_token_efficiency_failure_marked_error_not_inflated(monkeypatch):
"""A thrown get_review_context must yield status=error, not ratio=naive/1."""
from code_review_graph.eval.benchmarks import token_efficiency
def _boom(**kwargs):
raise RuntimeError("boom")
monkeypatch.setattr("code_review_graph.tools.get_review_context", _boom)
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = _make_repo(tmpdir)
store = _build_store(repo_path)
try:
results = token_efficiency.run(repo_path, store, _mock_config())
finally:
store.close()
assert len(results) >= 1
for row in results:
assert row["status"] == "error"
assert "boom" in row["error"]
# Failed measurements must not look like valid (inflated) ratios.
assert row["graph_tokens"] == ""
assert row["naive_to_graph_ratio"] == ""
assert row["standard_to_graph_ratio"] == ""
agg = token_efficiency.aggregate(results)
assert agg["ok_rows"] == 0
assert agg["error_rows"] == len(results)
assert agg["median_naive_to_graph_ratio"] is None
def test_token_efficiency_success_rows_status_ok():
from code_review_graph.eval.benchmarks import token_efficiency
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = _make_repo(tmpdir)
store = _build_store(repo_path)
try:
results = token_efficiency.run(repo_path, store, _mock_config())
finally:
store.close()
assert len(results) >= 1
for row in results:
assert row["status"] == "ok"
assert row["error"] == ""
assert isinstance(row["graph_tokens"], int)
assert isinstance(row["naive_to_graph_ratio"], float)
agg = token_efficiency.aggregate(results)
assert agg["ok_rows"] == len(results)
assert agg["error_rows"] == 0
assert isinstance(agg["median_naive_to_graph_ratio"], float)
def test_impact_accuracy_failure_marked_error_not_perfect_recall(monkeypatch):
"""A thrown analyze_changes must not silently score recall 1.0."""
from code_review_graph.eval.benchmarks import impact_accuracy
def _boom(*args, **kwargs):
raise RuntimeError("analysis exploded")
monkeypatch.setattr("code_review_graph.changes.analyze_changes", _boom)
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = _make_repo(tmpdir, two_file_commit=True)
store = _build_store(repo_path)
try:
results = impact_accuracy.run(repo_path, store, _mock_config())
finally:
store.close()
assert len(results) >= 2 # both modes attempted, both failed
for row in results:
assert row["status"] == "error"
assert "analysis exploded" in row["error"]
assert row["recall"] == "" # NOT 1.0
assert row["precision"] == ""
assert row["f1"] == ""
agg = impact_accuracy.aggregate(results)
assert agg["graph_derived"]["ok_rows"] == 0
assert agg["co_change"]["ok_rows"] == 0
assert agg["graph_derived"]["mean_recall"] is None
assert agg["error_rows"] == len(results)
def test_impact_accuracy_emits_both_ground_truth_modes():
from code_review_graph.eval.benchmarks import impact_accuracy
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = _make_repo(tmpdir, two_file_commit=True)
store = _build_store(repo_path)
try:
results = impact_accuracy.run(repo_path, store, _mock_config())
finally:
store.close()
modes = {r["ground_truth_mode"] for r in results}
assert impact_accuracy.MODE_GRAPH_DERIVED in modes
assert impact_accuracy.MODE_CO_CHANGE in modes
graph_rows = [
r for r in results
if r["ground_truth_mode"] == impact_accuracy.MODE_GRAPH_DERIVED
]
co_rows = [
r for r in results
if r["ground_truth_mode"] == impact_accuracy.MODE_CO_CHANGE
]
for row in graph_rows:
assert row["status"] == "ok"
assert 0.0 <= row["recall"] <= 1.0
assert row["seed_file"] == ""
# Commit touched helper.py + main.py: seed is the sorted-first file and
# the ground truth is the *other* co-changed file — independent of the graph.
assert len(co_rows) == 1
co = co_rows[0]
assert co["status"] == "ok"
assert co["seed_file"] == "helper.py"
assert co["actual_files"] == 1
assert 0.0 <= co["precision"] <= 1.0
assert 0.0 <= co["recall"] <= 1.0
def test_impact_accuracy_co_change_skipped_for_single_file_commit():
from code_review_graph.eval.benchmarks import impact_accuracy
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = _make_repo(tmpdir, two_file_commit=False)
store = _build_store(repo_path)
try:
results = impact_accuracy.run(repo_path, store, _mock_config())
finally:
store.close()
co_rows = [
r for r in results
if r["ground_truth_mode"] == impact_accuracy.MODE_CO_CHANGE
]
assert len(co_rows) == 1
assert co_rows[0]["status"] == "skipped"
assert "co-changed" in co_rows[0]["error"]
agg = impact_accuracy.aggregate(results)
assert agg["skipped_rows"] == 1
assert agg["co_change"]["ok_rows"] == 0
# --- agent_baseline benchmark ---
def test_derive_search_terms_extracts_identifiers_and_keywords():
from code_review_graph.eval.benchmarks.agent_baseline import derive_search_terms
terms = derive_search_terms("How does Client.request send an HTTP request?")
assert "client.request" in terms
assert "how" not in terms # stopword
assert "does" not in terms # stopword
assert all(t == t.lower() for t in terms)
def test_grep_rank_orders_by_match_count_and_takes_top_k():
from code_review_graph.eval.benchmarks.agent_baseline import grep_rank
with tempfile.TemporaryDirectory() as tmpdir:
corpus = Path(tmpdir)
(corpus / "a.py").write_text("greet()\ngreet()\ngreet()\n", encoding="utf-8")
(corpus / "b.py").write_text("greet()\n", encoding="utf-8")
(corpus / "c.py").write_text("nothing here\n", encoding="utf-8")
(corpus / "d.txt").write_text("greet greet greet greet\n", encoding="utf-8")
sub = corpus / "node_modules"
sub.mkdir()
(sub / "e.py").write_text("greet greet greet greet greet\n", encoding="utf-8")
ranked = grep_rank(corpus, ["greet"], k=3)
# d.txt (non-source ext) and node_modules/e.py (skipped dir) excluded
assert ranked == [("a.py", 3), ("b.py", 1)]
top1 = grep_rank(corpus, ["greet"], k=1)
assert top1 == [("a.py", 3)]
assert grep_rank(corpus, [], k=3) == []
def test_grep_rank_tie_breaks_on_path():
from code_review_graph.eval.benchmarks.agent_baseline import grep_rank
with tempfile.TemporaryDirectory() as tmpdir:
corpus = Path(tmpdir)
(corpus / "zz.py").write_text("token token\n", encoding="utf-8")
(corpus / "aa.py").write_text("token token\n", encoding="utf-8")
ranked = grep_rank(corpus, ["token"], k=2)
assert ranked == [("aa.py", 2), ("zz.py", 2)]
def test_agent_baseline_run_with_mock_repo():
from code_review_graph.eval.benchmarks import agent_baseline
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = _make_repo(tmpdir)
store = _build_store(repo_path)
config = _mock_config(
agent_questions=["How does greet print a greeting"],
)
try:
results = agent_baseline.run(repo_path, store, config)
finally:
store.close()
assert len(results) == 1
row = results[0]
assert row["question"] == "How does greet print a greeting"
assert "greet" in row["terms"]
assert row["files_matched"] >= 1
assert "helper.py" in row["top_files"]
assert row["baseline_tokens"] > 0
assert row["status"] in ("ok", "no_graph_results")
if row["status"] == "ok":
assert isinstance(row["baseline_to_graph_ratio"], float)
def test_agent_baseline_falls_back_to_search_queries():
from code_review_graph.eval.benchmarks import agent_baseline
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = _make_repo(tmpdir)
store = _build_store(repo_path)
try:
results = agent_baseline.run(repo_path, store, _mock_config())
finally:
store.close()
assert len(results) == 1
assert results[0]["question"] == "greet"
def test_agent_baseline_search_failure_marked_error(monkeypatch):
from code_review_graph.eval.benchmarks import agent_baseline
def _boom(*args, **kwargs):
raise RuntimeError("search down")
monkeypatch.setattr("code_review_graph.search.hybrid_search", _boom)
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = _make_repo(tmpdir)
store = _build_store(repo_path)
config = _mock_config(agent_questions=["How does greet work"])
try:
results = agent_baseline.run(repo_path, store, config)
finally:
store.close()
assert len(results) == 1
assert results[0]["status"] == "error"
assert "search down" in results[0]["error"]
assert results[0]["baseline_to_graph_ratio"] == ""
agg = agent_baseline.aggregate(results)
assert agg["ok_rows"] == 0
assert agg["error_rows"] == 1
assert agg["median_baseline_to_graph_ratio"] is None
def test_agent_baseline_aggregate_excludes_non_ok_rows():
from code_review_graph.eval.benchmarks import agent_baseline
rows = [
{"status": "ok", "baseline_to_graph_ratio": 4.0},
{"status": "ok", "baseline_to_graph_ratio": 8.0},
{"status": "error", "baseline_to_graph_ratio": ""},
{"status": "no_graph_results", "baseline_to_graph_ratio": ""},
]
agg = agent_baseline.aggregate(rows)
assert agg["total_rows"] == 4
assert agg["ok_rows"] == 2
assert agg["error_rows"] == 1
assert agg["median_baseline_to_graph_ratio"] == 6.0
@pytest.mark.skipif(not _HAS_YAML, reason="pyyaml not installed")
def test_agent_baseline_registered_in_runner():
from code_review_graph.eval.runner import BENCHMARK_REGISTRY
assert "agent_baseline" in BENCHMARK_REGISTRY
def test_reporter_impact_f1_skips_error_and_co_change_rows():
"""Table B must aggregate only ok graph-derived rows."""
with tempfile.TemporaryDirectory() as tmpdir:
results_dir = Path(tmpdir)
ia_path = results_dir / "mock_impact_accuracy_2026-01-01.csv"
fieldnames = [
"repo", "commit", "ground_truth_mode", "seed_file",
"predicted_files", "actual_files", "true_positives",
"precision", "recall", "f1", "status", "error",
]
with open(ia_path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fieldnames)
w.writeheader()
w.writerow({
"repo": "mock", "commit": "abc",
"ground_truth_mode": "graph-derived (circular — upper bound)",
"seed_file": "", "predicted_files": "2", "actual_files": "2",
"true_positives": "1", "precision": "0.5", "recall": "0.5",
"f1": "0.5", "status": "ok", "error": "",
})
w.writerow({
"repo": "mock", "commit": "def",
"ground_truth_mode": "graph-derived (circular — upper bound)",
"seed_file": "", "predicted_files": "", "actual_files": "",
"true_positives": "", "precision": "", "recall": "",
"f1": "", "status": "error", "error": "boom",
})
w.writerow({
"repo": "mock", "commit": "abc",
"ground_truth_mode": "co-change (same commit, seed excluded)",
"seed_file": "a.py", "predicted_files": "1", "actual_files": "1",
"true_positives": "1", "precision": "1.0", "recall": "1.0",
"f1": "0.9", "status": "ok", "error": "",
})
tables = generate_readme_tables(results_dir)
# 0.5 comes only from the single ok graph-derived row; the error row and
# the co-change row (different metric) must not pollute the column.
assert "0.5" in tables
assert "0.9" not in tables
+612
View File
@@ -0,0 +1,612 @@
"""Tests for execution flow detection, tracing, and scoring."""
import tempfile
from pathlib import Path
from code_review_graph.flows import (
detect_entry_points,
get_affected_flows,
get_flow_by_id,
get_flows,
incremental_trace_flows,
store_flows,
trace_flows,
)
from code_review_graph.graph import GraphStore
from code_review_graph.parser import EdgeInfo, NodeInfo
class TestFlows:
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
# -- helpers --
def _add_func(
self,
name: str,
path: str = "app.py",
parent: str | None = None,
is_test: bool = False,
extra: dict | None = None,
) -> int:
node = NodeInfo(
kind="Test" if is_test else "Function",
name=name,
file_path=path,
line_start=1,
line_end=10,
language="python",
parent_name=parent,
is_test=is_test,
extra=extra or {},
)
nid = self.store.upsert_node(node, file_hash="abc")
self.store.commit()
return nid
def _add_call(self, source_qn: str, target_qn: str, path: str = "app.py") -> None:
edge = EdgeInfo(
kind="CALLS",
source=source_qn,
target=target_qn,
file_path=path,
line=5,
)
self.store.upsert_edge(edge)
self.store.commit()
# ---------------------------------------------------------------
# detect_entry_points
# ---------------------------------------------------------------
def test_detect_entry_points_no_callers(self):
"""Functions with no incoming CALLS edges are entry points."""
self._add_func("entry_func")
self._add_func("helper")
# entry_func calls helper, so helper has an incoming CALLS.
self._add_call("app.py::entry_func", "app.py::helper")
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "entry_func" in ep_names
assert "helper" not in ep_names
def test_detect_entry_points_framework_pattern(self):
"""Decorated functions are entry points even if they have callers."""
self._add_func("get_users", extra={"decorators": ["app.get('/users')"]})
self._add_func("caller")
# caller -> get_users, so get_users has an incoming CALLS.
self._add_call("app.py::caller", "app.py::get_users")
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
# Even though get_users is called by someone, its decorator marks it.
assert "get_users" in ep_names
def test_detect_entry_points_name_pattern(self):
"""Functions matching name patterns (main, test_*, on_*) are entry points."""
self._add_func("main")
self._add_func("test_something")
self._add_func("on_message")
self._add_func("handle_request")
self._add_func("regular_func")
# Make regular_func called so it's not a root either
self._add_func("another")
self._add_call("app.py::another", "app.py::regular_func")
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "main" in ep_names
assert "test_something" in ep_names
assert "on_message" in ep_names
assert "handle_request" in ep_names
assert "regular_func" not in ep_names
# ---------------------------------------------------------------
# detect_entry_points -- expanded decorator patterns
# ---------------------------------------------------------------
def test_detect_entry_points_pytest_fixture(self):
"""pytest.fixture decorator marks function as entry point."""
self._add_func("my_fixture", extra={"decorators": ["pytest.fixture"]})
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "my_fixture" in ep_names
def test_detect_entry_points_django_receiver(self):
"""Django signal receiver decorator marks function as entry point."""
self._add_func("on_save", extra={"decorators": ["receiver(post_save)"]})
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "on_save" in ep_names
def test_detect_entry_points_spring_scheduled(self):
"""Java Spring @Scheduled marks function as entry point."""
self._add_func("cleanup_job", extra={"decorators": ["Scheduled(cron='0 0 * * *')"]})
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "cleanup_job" in ep_names
def test_detect_entry_points_celery_task(self):
"""Bare @task decorator marks function as entry point."""
self._add_func("process_data", extra={"decorators": ["task"]})
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "process_data" in ep_names
def test_detect_entry_points_agent_tool(self):
"""@agent.tool decorator marks function as entry point."""
self._add_func("query_health", extra={"decorators": ["health_agent.tool"]})
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "query_health" in ep_names
def test_detect_entry_points_alembic(self):
"""upgrade/downgrade functions are entry points."""
self._add_func("upgrade")
self._add_func("downgrade")
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "upgrade" in ep_names
assert "downgrade" in ep_names
def test_detect_entry_points_lifespan(self):
"""FastAPI lifespan function is an entry point."""
self._add_func("lifespan")
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "lifespan" in ep_names
# ---------------------------------------------------------------
# trace_flows
# ---------------------------------------------------------------
def test_detect_entry_points_excludes_tests_by_default(self):
"""Test nodes are excluded from entry points by default."""
self._add_func("production_handler")
self._add_func("it:should do something", is_test=True)
self.store.commit()
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "production_handler" in ep_names
assert "it:should do something" not in ep_names
# With include_tests=True, both appear
eps_all = detect_entry_points(self.store, include_tests=True)
ep_names_all = {ep.name for ep in eps_all}
assert "production_handler" in ep_names_all
assert "it:should do something" in ep_names_all
def test_detect_entry_points_excludes_test_files(self):
"""Functions in test files (*.spec.ts, *.test.ts) are excluded by default."""
self._add_func("production_func", path="src/handler.ts")
self._add_func("describe_block", path="src/handler.spec.ts")
self._add_func("test_helper", path="tests/__tests__/utils.ts")
eps = detect_entry_points(self.store)
ep_files = {ep.file_path for ep in eps}
assert "src/handler.ts" in ep_files
assert "src/handler.spec.ts" not in ep_files
assert "tests/__tests__/utils.ts" not in ep_files
# With include_tests=True, they appear
eps_all = detect_entry_points(self.store, include_tests=True)
ep_files_all = {ep.file_path for ep in eps_all}
assert "src/handler.spec.ts" in ep_files_all
def test_detect_entry_points_module_scope_caller_is_still_root(self):
"""A function called only from module scope (File-sourced CALLS) is a root.
Regression guard: the parser attributes module-scope calls to the File
node. Without filtering File-sourced callers, ``run_job`` here would
look "called" by ``script.py`` and be excluded from flow analysis,
even though in practice it IS an entry point (the script itself is
invoked externally).
"""
self._add_func("run_job", path="script.py")
# Ensure the File node exists so its qualified_name resolves cleanly
# (production code creates this automatically during parsing).
self.store.upsert_node(NodeInfo(
kind="File", name="script.py", file_path="script.py",
line_start=1, line_end=10, language="python",
))
self.store.commit()
# Module-scope call: source is the File node's qualified_name.
self._add_call("script.py", "script.py::run_job", path="script.py")
eps = detect_entry_points(self.store)
ep_names = {ep.name for ep in eps}
assert "run_job" in ep_names
def test_trace_simple_flow(self):
"""BFS traces a linear call chain: A -> B -> C."""
self._add_func("entry")
self._add_func("middle")
self._add_func("leaf")
self._add_call("app.py::entry", "app.py::middle")
self._add_call("app.py::middle", "app.py::leaf")
flows = trace_flows(self.store)
# entry should produce a flow with 3 nodes.
entry_flows = [f for f in flows if f["entry_point"] == "app.py::entry"]
assert len(entry_flows) == 1
assert entry_flows[0]["node_count"] == 3
assert entry_flows[0]["depth"] >= 1
def test_trace_flow_cycle_detection(self):
"""Cycles don't cause infinite loops."""
# main is an entry point (name pattern), calls a, which calls b,
# which calls a again (cycle).
self._add_func("main")
self._add_func("a")
self._add_func("b")
self._add_call("app.py::main", "app.py::a")
self._add_call("app.py::a", "app.py::b")
self._add_call("app.py::b", "app.py::a") # cycle back to a
# Should complete without hanging.
flows = trace_flows(self.store)
main_flows = [f for f in flows if f["entry_point"] == "app.py::main"]
assert len(main_flows) == 1
# main -> a -> b (a already visited, cycle skipped)
assert main_flows[0]["node_count"] == 3
def test_trace_flow_max_depth(self):
"""Respects max_depth limit."""
# Create a chain of 20 functions.
for i in range(20):
self._add_func(f"func_{i}")
for i in range(19):
self._add_call(f"app.py::func_{i}", f"app.py::func_{i+1}")
flows_shallow = trace_flows(self.store, max_depth=3)
entry_flow = [f for f in flows_shallow if f["entry_point"] == "app.py::func_0"]
assert len(entry_flow) == 1
# With max_depth=3, we should see at most 4 nodes (entry + 3 levels).
assert entry_flow[0]["node_count"] <= 4
def test_trace_flow_skips_trivial(self):
"""Flows with only a single node (no outgoing calls leading to graph nodes)
are excluded."""
self._add_func("lonely")
flows = trace_flows(self.store)
lonely_flows = [f for f in flows if f["entry_point"] == "app.py::lonely"]
assert len(lonely_flows) == 0
def test_trace_flow_multi_file(self):
"""Flows spanning multiple files track all files."""
self._add_func("api_handler", path="routes.py")
self._add_func("service_call", path="services.py")
self._add_func("db_query", path="db.py")
self._add_call("routes.py::api_handler", "services.py::service_call", "routes.py")
self._add_call("services.py::service_call", "db.py::db_query", "services.py")
flows = trace_flows(self.store)
handler_flows = [f for f in flows if f["entry_point"] == "routes.py::api_handler"]
assert len(handler_flows) == 1
assert handler_flows[0]["file_count"] == 3
assert set(handler_flows[0]["files"]) == {"routes.py", "services.py", "db.py"}
# ---------------------------------------------------------------
# compute_criticality
# ---------------------------------------------------------------
def test_criticality_scoring(self):
"""Criticality scores are between 0 and 1."""
self._add_func("entry")
self._add_func("helper")
self._add_call("app.py::entry", "app.py::helper")
flows = trace_flows(self.store)
for flow in flows:
assert 0.0 <= flow["criticality"] <= 1.0
def test_criticality_security_keywords_boost(self):
"""Flows touching security-sensitive functions score higher."""
# Non-security flow.
self._add_func("start")
self._add_func("process")
self._add_call("app.py::start", "app.py::process")
# Security flow.
self._add_func("login_handler", path="auth.py")
self._add_func("check_password", path="auth.py")
self._add_call("auth.py::login_handler", "auth.py::check_password", "auth.py")
flows = trace_flows(self.store)
normal_flows = [f for f in flows if f["entry_point"] == "app.py::start"]
secure_flows = [f for f in flows if f["entry_point"] == "auth.py::login_handler"]
assert len(normal_flows) == 1
assert len(secure_flows) == 1
# The security flow should have a higher criticality.
assert secure_flows[0]["criticality"] >= normal_flows[0]["criticality"]
def test_criticality_file_spread_boost(self):
"""Flows spanning more files score higher on file-spread."""
# Single-file flow.
self._add_func("single_a", path="one.py")
self._add_func("single_b", path="one.py")
self._add_call("one.py::single_a", "one.py::single_b", "one.py")
# Multi-file flow.
self._add_func("multi_a", path="a.py")
self._add_func("multi_b", path="b.py")
self._add_func("multi_c", path="c.py")
self._add_call("a.py::multi_a", "b.py::multi_b", "a.py")
self._add_call("b.py::multi_b", "c.py::multi_c", "b.py")
flows = trace_flows(self.store)
single = [f for f in flows if f["entry_point"] == "one.py::single_a"]
multi = [f for f in flows if f["entry_point"] == "a.py::multi_a"]
assert len(single) == 1
assert len(multi) == 1
assert multi[0]["criticality"] >= single[0]["criticality"]
# ---------------------------------------------------------------
# store_flows + get_flows roundtrip
# ---------------------------------------------------------------
def test_store_and_retrieve_flows(self):
"""store_flows + get_flows roundtrip works correctly."""
self._add_func("ep")
self._add_func("callee")
self._add_call("app.py::ep", "app.py::callee")
flows = trace_flows(self.store)
assert len(flows) >= 1
count = store_flows(self.store, flows)
assert count == len(flows)
retrieved = get_flows(self.store)
assert len(retrieved) >= 1
# Check that all expected fields are present.
flow = retrieved[0]
assert "id" in flow
assert "name" in flow
assert "criticality" in flow
assert "path" in flow
assert isinstance(flow["path"], list)
def test_store_flows_clears_old(self):
"""Calling store_flows replaces all previous flow data."""
self._add_func("ep1")
self._add_func("callee1")
self._add_call("app.py::ep1", "app.py::callee1")
flows_v1 = trace_flows(self.store)
store_flows(self.store, flows_v1)
assert len(get_flows(self.store)) >= 1
# Store an empty list — should clear everything.
store_flows(self.store, [])
assert len(get_flows(self.store)) == 0
def test_get_flow_by_id(self):
"""get_flow_by_id returns full step details."""
self._add_func("ep")
self._add_func("step1")
self._add_call("app.py::ep", "app.py::step1")
flows = trace_flows(self.store)
store_flows(self.store, flows)
stored = get_flows(self.store)
assert len(stored) >= 1
flow_id = stored[0]["id"]
detail = get_flow_by_id(self.store, flow_id)
assert detail is not None
assert "steps" in detail
assert len(detail["steps"]) >= 2
# Each step should have name, kind, file.
step = detail["steps"][0]
assert "name" in step
assert "kind" in step
assert "file" in step
def test_get_flow_by_id_not_found(self):
"""get_flow_by_id returns None for nonexistent flow."""
result = get_flow_by_id(self.store, 99999)
assert result is None
# ---------------------------------------------------------------
# get_affected_flows
# ---------------------------------------------------------------
def test_get_affected_flows(self):
"""Finds flows through changed files."""
self._add_func("handler", path="routes.py")
self._add_func("service", path="services.py")
self._add_func("repo", path="repo.py")
self._add_call("routes.py::handler", "services.py::service", "routes.py")
self._add_call("services.py::service", "repo.py::repo", "services.py")
flows = trace_flows(self.store)
store_flows(self.store, flows)
# Changing services.py should affect the handler flow.
result = get_affected_flows(self.store, ["services.py"])
assert result["total"] >= 1
affected_entries = {
f["entry_point_id"] for f in result["affected_flows"]
}
handler_node = self.store.get_node("routes.py::handler")
assert handler_node is not None
assert handler_node.id in affected_entries
def test_get_affected_flows_empty(self):
"""No affected flows when no files match."""
self._add_func("ep")
self._add_func("callee")
self._add_call("app.py::ep", "app.py::callee")
flows = trace_flows(self.store)
store_flows(self.store, flows)
result = get_affected_flows(self.store, ["nonexistent.py"])
assert result["total"] == 0
assert result["affected_flows"] == []
def test_get_affected_flows_no_files(self):
"""Empty changed_files list returns no results."""
result = get_affected_flows(self.store, [])
assert result["total"] == 0
# ---------------------------------------------------------------
# get_flows sorting
# ---------------------------------------------------------------
def test_get_flows_sorting(self):
"""get_flows respects sort_by parameter."""
self._add_func("shallow_ep", path="a.py")
self._add_func("shallow_callee", path="a.py")
self._add_call("a.py::shallow_ep", "a.py::shallow_callee", "a.py")
self._add_func("deep_ep", path="b.py")
self._add_func("deep_mid", path="c.py")
self._add_func("deep_end", path="d.py")
self._add_call("b.py::deep_ep", "c.py::deep_mid", "b.py")
self._add_call("c.py::deep_mid", "d.py::deep_end", "c.py")
flows = trace_flows(self.store)
store_flows(self.store, flows)
by_depth = get_flows(self.store, sort_by="depth")
assert len(by_depth) >= 2
# Deepest flow first.
assert by_depth[0]["depth"] >= by_depth[-1]["depth"]
# ---------------------------------------------------------------
# incremental_trace_flows
# ---------------------------------------------------------------
def test_incremental_trace_flows_no_changed_files(self):
"""Empty changed_files returns 0 and does nothing."""
assert incremental_trace_flows(self.store, []) == 0
def test_incremental_trace_flows_preserves_unrelated(self):
"""Flows not touching changed files survive an incremental update."""
# Flow A: routes.py -> services.py
self._add_func("handler", path="routes.py")
self._add_func("service", path="services.py")
self._add_call("routes.py::handler", "services.py::service", "routes.py")
# Flow B: cli.py -> utils.py (unrelated to routes/services)
self._add_func("main", path="cli.py")
self._add_func("helper", path="utils.py")
self._add_call("cli.py::main", "utils.py::helper", "cli.py")
# Store both flows
flows = trace_flows(self.store)
store_flows(self.store, flows)
initial = get_flows(self.store)
initial_count = len(initial)
assert initial_count >= 2
# Incrementally update only services.py — Flow A gets re-traced,
# Flow B stays untouched.
incremental_trace_flows(self.store, ["services.py"])
after = get_flows(self.store)
# Flow B should still be present.
cli_flows = [f for f in after if f["name"] == "main"]
assert len(cli_flows) == 1
def test_incremental_trace_flows_retraces_affected(self):
"""Affected flows are deleted and re-traced."""
self._add_func("handler", path="routes.py")
self._add_func("service", path="services.py")
self._add_func("repo", path="repo.py")
self._add_call("routes.py::handler", "services.py::service", "routes.py")
self._add_call("services.py::service", "repo.py::repo", "services.py")
flows = trace_flows(self.store)
store_flows(self.store, flows)
# Change services.py — the handler flow should be re-traced.
count = incremental_trace_flows(self.store, ["services.py"])
assert count >= 1
after = get_flows(self.store)
handler_flows = [f for f in after if f["name"] == "handler"]
assert len(handler_flows) == 1
assert handler_flows[0]["node_count"] == 3
def test_incremental_trace_flows_new_entry_point(self):
"""New entry points in changed files are discovered."""
# Start with one flow.
self._add_func("old_entry", path="a.py")
self._add_func("old_callee", path="a.py")
self._add_call("a.py::old_entry", "a.py::old_callee", "a.py")
flows = trace_flows(self.store)
store_flows(self.store, flows)
# Now add a new entry point in b.py.
self._add_func("new_entry", path="b.py")
self._add_func("new_callee", path="b.py")
self._add_call("b.py::new_entry", "b.py::new_callee", "b.py")
count = incremental_trace_flows(self.store, ["b.py"])
assert count >= 1
after = get_flows(self.store)
new_flows = [f for f in after if f["name"] == "new_entry"]
assert len(new_flows) == 1
def test_incremental_trace_flows_no_affected_flows(self):
"""When changed files have no existing flows, only new entry points are checked."""
self._add_func("handler", path="routes.py")
self._add_func("service", path="services.py")
self._add_call("routes.py::handler", "services.py::service", "routes.py")
flows = trace_flows(self.store)
store_flows(self.store, flows)
initial_count = len(get_flows(self.store))
# Change a file with no existing flow involvement and no entry points.
count = incremental_trace_flows(self.store, ["nonexistent.py"])
assert count == 0
# Original flows unchanged.
assert len(get_flows(self.store)) == initial_count
def test_incremental_trace_flows_delete_is_atomic(self):
"""Regression test for #258: the DELETE loop in incremental_trace_flows
must be wrapped in a transaction so a crash mid-loop cannot leave
orphaned flow_memberships rows."""
self._add_func("handler", path="routes.py")
self._add_func("service", path="services.py")
self._add_call("routes.py::handler", "services.py::service", "routes.py")
flows = trace_flows(self.store)
store_flows(self.store, flows)
assert len(get_flows(self.store)) > 0
# Incremental trace touching routes.py should delete old flows and
# re-trace them. The key assertion is that this does NOT raise
# "cannot start a transaction within a transaction" and that the
# DB ends in a consistent state.
count = incremental_trace_flows(self.store, ["routes.py"])
# The re-trace should find the same entry points.
assert count >= 0
# No orphaned memberships: every membership references a valid flow.
conn = self.store._conn
orphans = conn.execute(
"SELECT fm.flow_id FROM flow_memberships fm "
"LEFT JOIN flows f ON f.id = fm.flow_id "
"WHERE f.id IS NULL"
).fetchall()
assert len(orphans) == 0, f"found {len(orphans)} orphaned memberships"
+75
View File
@@ -0,0 +1,75 @@
"""Tests for FTS5 content sync robustness."""
import sqlite3
import tempfile
from pathlib import Path
import pytest
from code_review_graph.graph import GraphStore
from code_review_graph.parser import NodeInfo
from code_review_graph.search import rebuild_fts_index
@pytest.fixture
def store():
"""Create a temporary GraphStore for testing."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
db_path = tmp.name
store = GraphStore(db_path)
yield store
store.close()
Path(db_path).unlink(missing_ok=True)
class TestFTSSync:
def test_fts_rebuild_syncs_with_nodes(self, store):
"""Test that rebuild_fts_index properly populates from nodes table."""
# 1. Add some nodes
node1 = NodeInfo(
kind="Function", name="calculate_total", file_path="app.py",
line_start=1, line_end=5, language="python"
)
node2 = NodeInfo(
kind="Class", name="OrderProcessor", file_path="app.py",
line_start=10, line_end=50, language="python"
)
store.store_file_nodes_edges("app.py", [node1, node2], [])
# 2. Rebuild FTS
count = rebuild_fts_index(store)
assert count == 2
# 3. Verify FTS content via search
# We query the virtual table directly to ensure it has the data
fts_rows = store._conn.execute(
"SELECT name FROM nodes_fts WHERE name MATCH 'calculate*'"
).fetchall()
assert len(fts_rows) == 1
assert fts_rows[0]["name"] == "calculate_total"
def test_fts_rebuild_clears_old_data(self, store):
"""Test that rebuild_fts_index clears existing FTS data before repopulating."""
# 1. Add and index one node
node1 = NodeInfo(
kind="Function", name="old_func", file_path="old.py",
line_start=1, line_end=5, language="python"
)
store.store_file_nodes_edges("old.py", [node1], [])
rebuild_fts_index(store)
# 2. Delete the file/nodes
store.remove_file_data("old.py")
store.commit()
# 3. Add a new node
node2 = NodeInfo(
kind="Function", name="new_func", file_path="new.py",
line_start=1, line_end=5, language="python"
)
store.store_file_nodes_edges("new.py", [node2], [])
# 4. Rebuild FTS - should ONLY have new_func
rebuild_fts_index(store)
fts_rows = store._conn.execute("SELECT name FROM nodes_fts").fetchall()
assert len(fts_rows) == 1
assert fts_rows[0]["name"] == "new_func"
+416
View File
@@ -0,0 +1,416 @@
"""Tests for the graph storage and query engine."""
import logging
import sqlite3
import tempfile
from pathlib import Path
from code_review_graph.graph import GraphStore
from code_review_graph.parser import EdgeInfo, NodeInfo
class TestGraphStore:
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _make_file_node(self, path="/test/file.py"):
return NodeInfo(
kind="File", name=path, file_path=path,
line_start=1, line_end=100, language="python",
)
def _make_func_node(self, name="my_func", path="/test/file.py", parent=None, is_test=False):
return NodeInfo(
kind="Test" if is_test else "Function",
name=name, file_path=path,
line_start=10, line_end=20, language="python",
parent_name=parent, is_test=is_test,
)
def _make_class_node(self, name="MyClass", path="/test/file.py"):
return NodeInfo(
kind="Class", name=name, file_path=path,
line_start=5, line_end=50, language="python",
)
def test_upsert_and_get_node(self):
node = self._make_file_node()
self.store.upsert_node(node)
self.store.commit()
result = self.store.get_node("/test/file.py")
assert result is not None
assert result.kind == "File"
assert result.name == "/test/file.py"
def test_upsert_function_node(self):
func = self._make_func_node()
self.store.upsert_node(func)
self.store.commit()
result = self.store.get_node("/test/file.py::my_func")
assert result is not None
assert result.kind == "Function"
assert result.name == "my_func"
def test_upsert_method_node(self):
method = self._make_func_node(name="do_thing", parent="MyClass")
self.store.upsert_node(method)
self.store.commit()
result = self.store.get_node("/test/file.py::MyClass.do_thing")
assert result is not None
assert result.parent_name == "MyClass"
def test_upsert_edge(self):
edge = EdgeInfo(
kind="CALLS",
source="/test/file.py::func_a",
target="/test/file.py::func_b",
file_path="/test/file.py",
line=15,
)
self.store.upsert_edge(edge)
self.store.commit()
edges = self.store.get_edges_by_source("/test/file.py::func_a")
assert len(edges) == 1
assert edges[0].kind == "CALLS"
assert edges[0].target_qualified == "/test/file.py::func_b"
def test_remove_file_data(self):
node = self._make_file_node()
func = self._make_func_node()
self.store.upsert_node(node)
self.store.upsert_node(func)
self.store.commit()
self.store.remove_file_data("/test/file.py")
self.store.commit()
assert self.store.get_node("/test/file.py") is None
assert self.store.get_node("/test/file.py::my_func") is None
def test_store_file_nodes_edges(self):
nodes = [self._make_file_node(), self._make_func_node()]
edges = [
EdgeInfo(
kind="CONTAINS", source="/test/file.py",
target="/test/file.py::my_func", file_path="/test/file.py",
)
]
self.store.store_file_nodes_edges("/test/file.py", nodes, edges)
result = self.store.get_nodes_by_file("/test/file.py")
assert len(result) == 2
def test_store_after_remove_no_transaction_error(self):
"""Regression test for #135: store_file_nodes_edges after
remove_file_data must not raise 'cannot start a transaction
within a transaction'.
"""
# Seed initial data for two files
nodes_a = [self._make_file_node("/test/a.py")]
nodes_b = [self._make_file_node("/test/b.py")]
self.store.store_file_nodes_edges("/test/a.py", nodes_a, [])
self.store.store_file_nodes_edges("/test/b.py", nodes_b, [])
# Without the isolation_level=None fix, this would leave an
# implicit transaction open and the next call would crash.
self.store.remove_file_data("/test/a.py")
# Must not raise sqlite3.OperationalError
nodes_c = [self._make_file_node("/test/c.py")]
self.store.store_file_nodes_edges("/test/c.py", nodes_c, [])
assert self.store.get_node("/test/a.py") is None
assert self.store.get_node("/test/c.py") is not None
def test_store_after_multiple_removes_no_transaction_error(self):
"""Regression test for #181: full_build stale-file purge leaves
implicit transaction open after multiple remove_file_data calls.
"""
# Seed data for several files
for i in range(5):
path = f"/test/file_{i}.py"
self.store.store_file_nodes_edges(
path, [self._make_file_node(path)], [],
)
# Simulates full_build's stale-file purge: multiple deletes in a
# row without explicit commit between them.
for i in range(3):
self.store.remove_file_data(f"/test/file_{i}.py")
# Next store call must succeed regardless of prior connection state.
new_path = "/test/new_file.py"
nodes = [self._make_file_node(new_path)]
self.store.store_file_nodes_edges(new_path, nodes, [])
assert self.store.get_node(new_path) is not None
assert self.store.get_node("/test/file_0.py") is None
def test_store_with_open_transaction_no_error(self):
"""Regression test for #489: store_file_nodes_edges and
store_file_batch must not raise 'cannot start a transaction
within a transaction' when the caller has an explicit BEGIN open.
"""
node_a = self._make_file_node("/test/a.py")
node_b = self._make_file_node("/test/b.py")
# Force an open transaction on the shared connection.
self.store._conn.execute("BEGIN")
assert self.store._conn.in_transaction
# Must not raise sqlite3.OperationalError.
self.store.store_file_nodes_edges("/test/a.py", [node_a], [])
assert self.store.get_node("/test/a.py") is not None
# Re-open the transaction and verify the batch path is guarded too.
self.store._conn.execute("BEGIN")
assert self.store._conn.in_transaction
self.store.store_file_batch([("/test/b.py", [node_b], [], "")])
assert self.store.get_node("/test/b.py") is not None
def test_search_nodes(self):
self.store.upsert_node(self._make_func_node("authenticate"))
self.store.upsert_node(self._make_func_node("authorize"))
self.store.upsert_node(self._make_func_node("process"))
self.store.commit()
results = self.store.search_nodes("auth")
names = {r.name for r in results}
assert "authenticate" in names
assert "authorize" in names
assert "process" not in names
def test_get_stats(self):
self.store.upsert_node(self._make_file_node())
self.store.upsert_node(self._make_func_node())
self.store.upsert_node(self._make_class_node())
self.store.upsert_edge(EdgeInfo(
kind="CONTAINS", source="/test/file.py",
target="/test/file.py::my_func", file_path="/test/file.py",
))
self.store.commit()
stats = self.store.get_stats()
assert stats.total_nodes == 3
assert stats.total_edges == 1
assert stats.nodes_by_kind["File"] == 1
assert stats.nodes_by_kind["Function"] == 1
assert stats.nodes_by_kind["Class"] == 1
assert "python" in stats.languages
def test_impact_radius(self):
# Create a chain: file_a -> func_a -> (calls) -> func_b in file_b
self.store.upsert_node(self._make_file_node("/a.py"))
self.store.upsert_node(self._make_func_node("func_a", "/a.py"))
self.store.upsert_node(self._make_file_node("/b.py"))
self.store.upsert_node(self._make_func_node("func_b", "/b.py"))
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="/a.py::func_a",
target="/b.py::func_b", file_path="/a.py", line=10,
))
self.store.commit()
result = self.store.get_impact_radius(["/a.py"], max_depth=2)
assert len(result["changed_nodes"]) > 0
# func_b in /b.py should be impacted
impacted_qns = {n.qualified_name for n in result["impacted_nodes"]}
assert "/b.py::func_b" in impacted_qns or "/b.py" in impacted_qns
def test_upsert_edge_preserves_multiple_call_sites(self):
"""Multiple CALLS edges to the same target from the same source on different lines."""
edge1 = EdgeInfo(
kind="CALLS", source="/test/file.py::caller",
target="/test/file.py::helper", file_path="/test/file.py", line=10,
)
edge2 = EdgeInfo(
kind="CALLS", source="/test/file.py::caller",
target="/test/file.py::helper", file_path="/test/file.py", line=20,
)
self.store.upsert_edge(edge1)
self.store.upsert_edge(edge2)
self.store.commit()
edges = self.store.get_edges_by_source("/test/file.py::caller")
assert len(edges) == 2
lines = {e.line for e in edges}
assert lines == {10, 20}
def test_metadata(self):
self.store.set_metadata("test_key", "test_value")
assert self.store.get_metadata("test_key") == "test_value"
assert self.store.get_metadata("nonexistent") is None
def test_get_all_community_ids_logs_when_column_missing(self, caplog):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute(
"CREATE TABLE nodes (qualified_name TEXT PRIMARY KEY)"
)
store = GraphStore.__new__(GraphStore)
store._conn = conn
with caplog.at_level(logging.DEBUG, logger="code_review_graph.graph"):
result = store.get_all_community_ids()
assert result == {}
assert "Community IDs unavailable" in caplog.text
conn.close()
def test_get_communities_list_logs_when_table_missing(self, caplog):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
store = GraphStore.__new__(GraphStore)
store._conn = conn
with caplog.at_level(logging.DEBUG, logger="code_review_graph.graph"):
result = store.get_communities_list()
assert result == []
assert "Communities list unavailable" in caplog.text
conn.close()
class TestImpactRadiusSql:
"""Tests for get_impact_radius_sql vs NetworkX BFS."""
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self._build_chain()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _build_chain(self):
"""Build A -> B -> C -> D chain for testing."""
for name, path in [
("func_a", "/a.py"), ("func_b", "/b.py"),
("func_c", "/c.py"), ("func_d", "/d.py"),
]:
self.store.upsert_node(NodeInfo(
kind="File", name=path, file_path=path,
line_start=1, line_end=50, language="python",
))
self.store.upsert_node(NodeInfo(
kind="Function", name=name, file_path=path,
line_start=5, line_end=20, language="python",
))
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="/a.py::func_a",
target="/b.py::func_b", file_path="/a.py", line=10,
))
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="/b.py::func_b",
target="/c.py::func_c", file_path="/b.py", line=10,
))
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="/c.py::func_c",
target="/d.py::func_d", file_path="/c.py", line=10,
))
self.store.commit()
def test_sql_matches_networkx(self):
"""SQL and NetworkX BFS produce identical impacted node sets."""
sql_result = self.store.get_impact_radius_sql(["/a.py"], max_depth=2)
nx_result = self.store._get_impact_radius_networkx(["/a.py"], max_depth=2)
sql_qns = {n.qualified_name for n in sql_result["impacted_nodes"]}
nx_qns = {n.qualified_name for n in nx_result["impacted_nodes"]}
assert sql_qns == nx_qns
def test_max_nodes_truncation(self):
"""Setting max_nodes=2 should truncate results."""
result = self.store.get_impact_radius_sql(
["/a.py"], max_depth=3, max_nodes=2,
)
# With 4 files in chain + file nodes, max_nodes=2 should limit
assert result["total_impacted"] <= 2 or result["truncated"]
def test_empty_changed_files(self):
result = self.store.get_impact_radius_sql([], max_depth=2)
assert result["changed_nodes"] == []
assert result["impacted_nodes"] == []
assert result["total_impacted"] == 0
class TestGetTransitiveTestsFrontierCap:
"""Regression tests for O(N*M) query explosion in get_transitive_tests."""
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _add_func(self, name: str, path: str) -> str:
node = NodeInfo(
kind="Function", name=name, file_path=path,
line_start=1, line_end=5, language="python",
)
self.store.upsert_node(node)
return f"{path}::{name}"
def _add_calls_edge(self, source_qn: str, target_qn: str) -> None:
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source=source_qn, target=target_qn,
file_path=source_qn.split("::")[0], line=1,
))
def test_frontier_capped_limits_sql_queries(self):
"""Hub function with 200 callees must not issue 200 TESTED_BY queries."""
hub_qn = self._add_func("hub", "/t/hub.py")
for i in range(200):
callee_qn = self._add_func(f"callee_{i}", "/t/callee.py")
self._add_calls_edge(hub_qn, callee_qn)
self.store.commit()
query_count = 0
def _trace(stmt: str) -> None:
nonlocal query_count
query_count += 1
self.store._conn.set_trace_callback(_trace)
self.store.get_transitive_tests(hub_qn, max_frontier=50)
self.store._conn.set_trace_callback(None)
# Without cap: 200 callee TESTED_BY queries + overhead = ~204
# With cap of 50: ~54 queries max
assert query_count <= 60, (
f"Expected <=60 queries with frontier cap, got {query_count}"
)
def test_uncapped_small_frontier_unchanged(self):
"""Small fan-out (< cap) returns same results regardless of cap."""
hub_qn = self._add_func("hub", "/t/hub.py")
test_qn = self._add_func("test_hub", "/t/test_hub.py")
for i in range(5):
callee_qn = self._add_func(f"callee_{i}", "/t/callee.py")
self._add_calls_edge(hub_qn, callee_qn)
# Only callee_2 has a test
if i == 2:
self.store.upsert_edge(EdgeInfo(
kind="TESTED_BY", source=test_qn, target=callee_qn,
file_path="/t/test_hub.py", line=1,
))
self.store.commit()
results_default = self.store.get_transitive_tests(hub_qn)
results_capped = self.store.get_transitive_tests(hub_qn, max_frontier=50)
indirect_default = [r for r in results_default if r["indirect"]]
indirect_capped = [r for r in results_capped if r["indirect"]]
assert len(indirect_default) == 1
assert len(indirect_capped) == 1
assert indirect_default[0]["name"] == indirect_capped[0]["name"]
+195
View File
@@ -0,0 +1,195 @@
"""Tests for the context-aware hints system."""
from code_review_graph.hints import (
_MAX_PER_CATEGORY,
SessionState,
generate_hints,
get_session,
infer_intent,
reset_session,
)
class TestSessionState:
def test_fresh_session_exploring(self):
"""A brand-new session with no history should infer 'exploring'."""
session = SessionState()
assert infer_intent(session) == "exploring"
def test_review_intent_detected(self):
"""Recording review-oriented tools should infer 'reviewing'."""
session = SessionState()
for tool in ("detect_changes", "get_review_context", "get_affected_flows"):
session.record_tool_call(tool)
assert infer_intent(session) == "reviewing"
def test_debug_intent_detected(self):
"""Recording debug-oriented tools should infer 'debugging'."""
session = SessionState()
for tool in ("query_graph", "get_flow", "semantic_search_nodes"):
session.record_tool_call(tool)
assert infer_intent(session) == "debugging"
def test_refactoring_intent_detected(self):
"""Recording refactoring-oriented tools should infer 'refactoring'."""
session = SessionState()
for tool in ("refactor", "find_dead_code", "suggest_refactorings"):
session.record_tool_call(tool)
assert infer_intent(session) == "refactoring"
def test_session_caps_history(self):
"""tools_called should never exceed 100 entries (FIFO)."""
session = SessionState()
for i in range(150):
session.record_tool_call(f"tool_{i}")
assert len(session.tools_called) == 100
# Oldest entries should have been evicted
assert "tool_0" not in session.tools_called
assert "tool_149" in session.tools_called
def test_nodes_capped_at_1000(self):
"""nodes_queried should stop growing at 1000."""
session = SessionState()
session.record_nodes([f"node_{i}" for i in range(1200)])
assert len(session.nodes_queried) == 1000
class TestGenerateHints:
def test_hints_no_repeat(self):
"""Already-called tools must not appear in next_steps."""
session = SessionState()
# Call list_flows, then generate hints for it
# list_flows suggests get_flow, get_affected_flows, get_architecture_overview
generate_hints("list_flows", {"status": "ok"}, session)
# Now call get_flow and regenerate hints for list_flows
hints2 = generate_hints("list_flows", {"status": "ok"}, session)
suggested_tools2 = {s["tool"] for s in hints2["next_steps"]}
# list_flows itself was called, so it shouldn't be suggested by get_flow workflow
# Also, the first list_flows call should be excluded from next suggestions
assert "list_flows" not in suggested_tools2
def test_hints_max_three(self):
"""Each hints category should have at most 3 entries."""
session = SessionState()
# detect_changes has 4 workflow entries
result = {
"status": "ok",
"test_gaps": [{"name": f"gap_{i}"} for i in range(10)],
"risk_score": 0.9,
"warnings": ["coupling warning 1", "coupling warning 2"],
}
hints = generate_hints("detect_changes", result, session)
assert len(hints["next_steps"]) <= _MAX_PER_CATEGORY
assert len(hints["warnings"]) <= _MAX_PER_CATEGORY
assert len(hints["related"]) <= _MAX_PER_CATEGORY
def test_warnings_from_result_test_gaps(self):
"""test_gaps in result should produce a warning."""
session = SessionState()
result = {
"status": "ok",
"test_gaps": [{"name": "untested_func"}, {"name": "another_func"}],
}
hints = generate_hints("detect_changes", result, session)
assert any("Test coverage gaps" in w for w in hints["warnings"])
assert any("untested_func" in w for w in hints["warnings"])
def test_warnings_from_result_risk_score(self):
"""High risk_score in result should produce a warning."""
session = SessionState()
result = {"status": "ok", "risk_score": 0.85}
hints = generate_hints("detect_changes", result, session)
assert any("High risk score" in w for w in hints["warnings"])
def test_warnings_low_risk_no_warning(self):
"""Low risk_score should NOT produce a warning."""
session = SessionState()
result = {"status": "ok", "risk_score": 0.3}
hints = generate_hints("detect_changes", result, session)
assert not any("High risk score" in w for w in hints["warnings"])
def test_generate_hints_empty_result(self):
"""An empty/minimal result should still return valid hints structure."""
session = SessionState()
hints = generate_hints("list_flows", {}, session)
assert "next_steps" in hints
assert "related" in hints
assert "warnings" in hints
assert isinstance(hints["next_steps"], list)
assert isinstance(hints["related"], list)
assert isinstance(hints["warnings"], list)
def test_generate_hints_unknown_tool(self):
"""An unrecognized tool name should still return valid hints."""
session = SessionState()
hints = generate_hints("nonexistent_tool", {"status": "ok"}, session)
assert hints["next_steps"] == []
assert hints["warnings"] == []
def test_session_records_files(self):
"""Files from result should be tracked in session state."""
session = SessionState()
result = {"status": "ok", "changed_files": ["a.py", "b.py"]}
generate_hints("detect_changes", result, session)
assert "a.py" in session.files_touched
assert "b.py" in session.files_touched
def test_session_records_nodes(self):
"""Nodes from result should be tracked in session state."""
session = SessionState()
result = {
"status": "ok",
"results": [
{"qualified_name": "mod.py::Foo", "name": "Foo"},
{"qualified_name": "mod.py::Bar", "name": "Bar"},
],
}
generate_hints("semantic_search_nodes", result, session)
assert "mod.py::Foo" in session.nodes_queried
assert "mod.py::Bar" in session.nodes_queried
def test_related_suggests_untouched_files(self):
"""Related should suggest impacted files not yet touched."""
session = SessionState()
session.record_files(["already_seen.py"])
result = {
"status": "ok",
"impacted_files": ["already_seen.py", "new_file.py", "other.py"],
}
hints = generate_hints("detect_changes", result, session)
assert "already_seen.py" not in hints["related"]
assert "new_file.py" in hints["related"]
class TestGlobalSession:
def test_get_session_returns_singleton(self):
"""get_session should return the same object each time."""
reset_session()
s1 = get_session()
s2 = get_session()
assert s1 is s2
def test_reset_session_creates_new(self):
"""reset_session should replace the global session."""
reset_session()
s1 = get_session()
s1.record_tool_call("foo")
reset_session()
s2 = get_session()
assert len(s2.tools_called) == 0
assert s1 is not s2
def test_warnings_from_arch_overview_dict(self):
"""Architecture warnings as dicts with 'message' key should be extracted."""
session = SessionState()
result = {
"status": "ok",
"warnings": [
{"message": "High coupling between A and B"},
{"message": "Circular dependency detected"},
],
}
hints = generate_hints("get_architecture_overview", result, session)
assert any("High coupling" in w for w in hints["warnings"])
assert any("Circular dependency" in w for w in hints["warnings"])
+870
View File
@@ -0,0 +1,870 @@
"""Tests for the incremental graph update module."""
import subprocess
from unittest.mock import MagicMock, patch # noqa: F401 patch used in tests
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import (
_is_binary,
_load_ignore_patterns,
_parse_single_file,
_should_ignore,
_single_hop_dependents,
ensure_repo_gitignore_excludes_crg,
find_dependents,
find_project_root,
find_repo_root,
full_build,
get_all_tracked_files,
get_changed_files,
get_db_path,
get_staged_and_unstaged,
incremental_update,
start_watch_thread,
)
class TestFindRepoRoot:
def test_finds_git_dir(self, tmp_path):
(tmp_path / ".git").mkdir()
assert find_repo_root(tmp_path) == tmp_path
def test_finds_parent_git_dir(self, tmp_path):
(tmp_path / ".git").mkdir()
sub = tmp_path / "a" / "b"
sub.mkdir(parents=True)
assert find_repo_root(sub) == tmp_path
def test_returns_none_without_git(self, tmp_path):
"""No .git between ``sub`` and ``tmp_path`` -> None.
Bounded with ``stop_at=tmp_path`` so the walk does not climb into
ancestors outside the test sandbox. On Windows in particular,
``tmp_path`` lives under ``C:/Users/<user>/AppData/Local/Temp/...``
and if the user has ``git init`` anywhere under their home (dotfiles,
chezmoi, etc.) the unbounded walk would find that ancestor .git and
the test would fail for reasons unrelated to the product. See #241.
"""
sub = tmp_path / "no_git"
sub.mkdir()
assert find_repo_root(sub, stop_at=tmp_path) is None
def test_stop_at_prevents_escape_to_outer_git(self, tmp_path):
"""Positive regression test for #241: ``stop_at`` must halt the
walk even when an ancestor *does* contain ``.git``.
Without ``stop_at`` the walk correctly finds the outer .git; with
``stop_at=inner`` the walk is bounded and returns None.
"""
outer = tmp_path / "outer"
outer.mkdir()
(outer / ".git").mkdir()
inner = outer / "inner"
inner.mkdir()
# Unbounded walk finds the ancestor .git (existing behavior).
assert find_repo_root(inner) == outer
# Bounded walk stops at ``inner`` and never climbs to ``outer``.
assert find_repo_root(inner, stop_at=inner) is None
def test_stop_at_finds_git_at_boundary(self, tmp_path):
"""stop_at does not suppress a .git that lives *at* the boundary."""
boundary = tmp_path / "boundary"
boundary.mkdir()
(boundary / ".git").mkdir()
inner = boundary / "inner"
inner.mkdir()
# The walk examines ``boundary`` and finds the .git before stopping.
assert find_repo_root(inner, stop_at=boundary) == boundary
class TestFindProjectRoot:
def test_returns_git_root(self, tmp_path):
(tmp_path / ".git").mkdir()
assert find_project_root(tmp_path) == tmp_path
def test_falls_back_to_start(self, tmp_path, monkeypatch):
"""With no .git and no env override, find_project_root returns ``sub``.
Bounded with ``stop_at=tmp_path`` to prevent the ancestor walk from
escaping the test sandbox (see #241), and ``CRG_REPO_ROOT`` is
cleared so a developer env var cannot shadow the test expectation.
"""
monkeypatch.delenv("CRG_REPO_ROOT", raising=False)
sub = tmp_path / "no_git"
sub.mkdir()
assert find_project_root(sub, stop_at=tmp_path) == sub
def test_stop_at_forwarded_to_find_repo_root(self, tmp_path, monkeypatch):
"""Positive regression test for #241: find_project_root must forward
stop_at to find_repo_root, not silently drop it."""
monkeypatch.delenv("CRG_REPO_ROOT", raising=False)
outer = tmp_path / "outer"
outer.mkdir()
(outer / ".git").mkdir()
inner = outer / "inner"
inner.mkdir()
# Without stop_at, find_project_root climbs to outer (existing behavior).
assert find_project_root(inner) == outer
# With stop_at=inner, the walk is bounded and find_project_root falls
# back to its third resolution rule (the start path itself).
assert find_project_root(inner, stop_at=inner) == inner
class TestGetDbPath:
def test_creates_directory_and_db_path(self, tmp_path):
db_path = get_db_path(tmp_path)
assert db_path == tmp_path / ".code-review-graph" / "graph.db"
assert (tmp_path / ".code-review-graph").is_dir()
def test_creates_gitignore(self, tmp_path):
get_db_path(tmp_path)
gi = tmp_path / ".code-review-graph" / ".gitignore"
assert gi.exists()
assert "*\n" in gi.read_text()
def test_migrates_legacy_db(self, tmp_path):
legacy = tmp_path / ".code-review-graph.db"
legacy.write_text("legacy data")
db_path = get_db_path(tmp_path)
assert db_path.exists()
assert not legacy.exists()
assert db_path.read_text() == "legacy data"
def test_cleans_legacy_side_files(self, tmp_path):
legacy = tmp_path / ".code-review-graph.db"
legacy.write_text("data")
for suffix in ("-wal", "-shm", "-journal"):
(tmp_path / f".code-review-graph.db{suffix}").write_text("side")
get_db_path(tmp_path)
for suffix in ("-wal", "-shm", "-journal"):
assert not (tmp_path / f".code-review-graph.db{suffix}").exists()
class TestEnsureRepoGitignoreExcludesCrg:
def test_creates_gitignore_when_missing(self, tmp_path):
state = ensure_repo_gitignore_excludes_crg(tmp_path)
assert state == "created"
gitignore = tmp_path / ".gitignore"
assert gitignore.exists()
assert gitignore.read_text() == (
"# Added by code-review-graph\n"
".code-review-graph/\n"
)
def test_appends_rule_when_missing(self, tmp_path):
gitignore = tmp_path / ".gitignore"
gitignore.write_text("node_modules/\n")
state = ensure_repo_gitignore_excludes_crg(tmp_path)
assert state == "updated"
assert gitignore.read_text() == (
"node_modules/\n"
"# Added by code-review-graph\n"
".code-review-graph/\n"
)
def test_idempotent_when_present(self, tmp_path):
gitignore = tmp_path / ".gitignore"
gitignore.write_text(".code-review-graph/\n")
state = ensure_repo_gitignore_excludes_crg(tmp_path)
assert state == "already-present"
assert gitignore.read_text() == ".code-review-graph/\n"
def test_treats_wildcard_ignore_as_present(self, tmp_path):
gitignore = tmp_path / ".gitignore"
gitignore.write_text(".code-review-graph/**\n")
state = ensure_repo_gitignore_excludes_crg(tmp_path)
assert state == "already-present"
class TestIgnorePatterns:
def test_default_patterns_loaded(self, tmp_path):
patterns = _load_ignore_patterns(tmp_path)
assert "node_modules/**" in patterns
assert ".git/**" in patterns
assert "__pycache__/**" in patterns
def test_custom_ignore_file(self, tmp_path):
ignore = tmp_path / ".code-review-graphignore"
ignore.write_text("custom/**\n# comment\n\nvendor/**\n")
patterns = _load_ignore_patterns(tmp_path)
assert "custom/**" in patterns
assert "vendor/**" in patterns
# Comments and blanks should be skipped
assert "# comment" not in patterns
assert "" not in patterns
def test_should_ignore_matches(self):
patterns = ["node_modules/**", "*.pyc", ".git/**"]
assert _should_ignore("node_modules/foo/bar.js", patterns)
assert _should_ignore("test.pyc", patterns)
assert _should_ignore(".git/HEAD", patterns)
assert not _should_ignore("src/main.py", patterns)
def test_should_ignore_nested_dependency_dirs(self):
"""Nested node_modules / vendor / .gradle should be ignored (#91)."""
patterns = [
"node_modules/**", "vendor/**", ".gradle/**", ".venv/**",
]
# Monorepo: nested node_modules
assert _should_ignore("packages/app/node_modules/react/index.js", patterns)
assert _should_ignore("apps/web/node_modules/lodash/index.js", patterns)
# PHP/Laravel: vendor at any depth
assert _should_ignore("backend/vendor/autoload.php", patterns)
# Gradle at any depth
assert _should_ignore("android/app/.gradle/cache/metadata.bin", patterns)
# Negative: similarly-named dirs that aren't a match
assert not _should_ignore("src/node_modules_helper/foo.py", patterns)
assert not _should_ignore("src/venv_tools/bar.py", patterns)
def test_should_ignore_framework_defaults(self):
"""Default patterns should cover Laravel, Gradle, Flutter, and caches."""
from code_review_graph.incremental import DEFAULT_IGNORE_PATTERNS
patterns = DEFAULT_IGNORE_PATTERNS
# Laravel/PHP
assert _should_ignore("vendor/autoload.php", patterns)
assert _should_ignore("bootstrap/cache/packages.php", patterns)
# Gradle/Java
assert _should_ignore(".gradle/caches/jars.bin", patterns)
assert _should_ignore("build/libs/app.jar", patterns)
# Flutter/Dart
assert _should_ignore(".dart_tool/package_config.json", patterns)
# Coverage/cache
assert _should_ignore("coverage/lcov.info", patterns)
assert _should_ignore(".cache/webpack/index.pack", patterns)
class TestDataDir:
"""Tests for get_data_dir / CRG_DATA_DIR / CRG_REPO_ROOT (#155)."""
def test_default_uses_repo_subdir(self, tmp_path, monkeypatch):
"""Without CRG_DATA_DIR, graphs live at <repo>/.code-review-graph."""
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
from code_review_graph.incremental import get_data_dir
result = get_data_dir(tmp_path)
assert result == tmp_path / ".code-review-graph"
assert result.is_dir()
# Auto-generated gitignore must exist
assert (result / ".gitignore").is_file()
content = (result / ".gitignore").read_text(encoding="utf-8")
assert content.strip().endswith("*")
def test_auto_gitignore_is_valid_utf8(self, tmp_path, monkeypatch):
"""Regression guard for #239 bug 1: the auto-generated .gitignore
must be written as UTF-8 on every platform.
Before the fix, ``write_text()`` was called without an encoding
argument. The header contains an em-dash (U+2014) which Python
writes using the system default codepage on Windows (cp1252 →
byte 0x97), producing a file that cannot be decoded as UTF-8.
"""
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
from code_review_graph.incremental import get_data_dir
data_dir = get_data_dir(tmp_path)
gi = data_dir / ".gitignore"
assert gi.is_file()
# The file must be valid UTF-8 — this is what actually broke.
raw = gi.read_bytes()
# The em-dash must be stored as the proper UTF-8 sequence (0xE2 0x80 0x94),
# not as the cp1252 single byte 0x97.
assert b"\xe2\x80\x94" in raw, (
"auto-generated .gitignore is missing the UTF-8 em-dash; it was "
"probably written using the platform default codepage"
)
assert b"\x97" not in raw, (
"auto-generated .gitignore contains cp1252 byte 0x97 — indicates "
"write_text was called without encoding='utf-8'"
)
# And it must round-trip cleanly under strict UTF-8 decoding.
decoded = raw.decode("utf-8", errors="strict")
assert "" in decoded, "em-dash missing from decoded gitignore"
def test_env_override_replaces_repo_subdir(self, tmp_path, monkeypatch):
"""CRG_DATA_DIR replaces the default <repo>/.code-review-graph."""
external = tmp_path / "external-graphs"
repo = tmp_path / "project"
repo.mkdir()
monkeypatch.setenv("CRG_DATA_DIR", str(external))
from code_review_graph.incremental import get_data_dir
result = get_data_dir(repo)
assert result == external.resolve()
assert result.is_dir()
# The repo itself should NOT have a .code-review-graph dir now
assert not (repo / ".code-review-graph").exists()
def test_get_db_path_uses_data_dir(self, tmp_path, monkeypatch):
"""get_db_path should honor CRG_DATA_DIR too."""
external = tmp_path / "external"
repo = tmp_path / "project"
repo.mkdir()
monkeypatch.setenv("CRG_DATA_DIR", str(external))
from code_review_graph.incremental import get_db_path
db_path = get_db_path(repo)
assert db_path == external.resolve() / "graph.db"
assert db_path.parent.is_dir()
def test_find_project_root_env_override(self, tmp_path, monkeypatch):
"""CRG_REPO_ROOT should override normal git-root resolution."""
from pathlib import Path as PathType
external_repo = tmp_path / "elsewhere"
external_repo.mkdir()
monkeypatch.setenv("CRG_REPO_ROOT", str(external_repo))
from code_review_graph.incremental import find_project_root
result = find_project_root(PathType.cwd())
assert result == external_repo.resolve()
def test_find_project_root_env_override_missing_dir_falls_through(
self, tmp_path, monkeypatch,
):
"""CRG_REPO_ROOT pointing at a non-existent path falls back to
the usual resolution rather than crashing."""
monkeypatch.setenv(
"CRG_REPO_ROOT", str(tmp_path / "does-not-exist-123"),
)
from code_review_graph.incremental import find_project_root
result = find_project_root(tmp_path)
# Should NOT equal the bogus env value
assert result != tmp_path / "does-not-exist-123"
class TestDataDirRegistry:
"""Tests for registry-based data_dir resolution."""
def test_registry_data_dir_overrides_default(self, tmp_path, monkeypatch):
"""Registry data_dir should override default .code-review-graph."""
from code_review_graph.incremental import get_data_dir
from code_review_graph.registry import Registry
repo = tmp_path / "project"
repo.mkdir()
external = tmp_path / "external"
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
# Set in registry
registry = Registry()
registry.set_data_dir(str(repo), str(external))
result = get_data_dir(repo)
assert result == external.resolve()
assert result.is_dir()
assert not (repo / ".code-review-graph").exists()
def test_registry_data_dir_overrides_env_var(self, tmp_path, monkeypatch):
"""Registry data_dir should override CRG_DATA_DIR."""
from code_review_graph.incremental import get_data_dir
from code_review_graph.registry import Registry
repo = tmp_path / "project"
repo.mkdir()
registry_dir = tmp_path / "registry-data"
env_dir = tmp_path / "env-data"
monkeypatch.setenv("CRG_DATA_DIR", str(env_dir))
# Set in registry
registry = Registry()
registry.set_data_dir(str(repo), str(registry_dir))
result = get_data_dir(repo)
# Registry should win over env var
assert result == registry_dir.resolve()
assert not env_dir.exists()
def test_registry_fallback_to_env_var(self, tmp_path, monkeypatch):
"""Fall back to CRG_DATA_DIR when registry has no entry."""
from code_review_graph.incremental import get_data_dir
from code_review_graph.registry import Registry
repo = tmp_path / "project"
repo.mkdir()
env_dir = tmp_path / "env-data"
monkeypatch.setenv("CRG_DATA_DIR", str(env_dir))
# Don't set in registry
result = get_data_dir(repo)
assert result == env_dir.resolve()
assert result.is_dir()
def test_registry_fallback_to_default(self, tmp_path, monkeypatch):
"""Fall back to default when neither registry nor env var is set."""
from code_review_graph.incremental import get_data_dir
from code_review_graph.registry import Registry
repo = tmp_path / "project"
repo.mkdir()
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
# Don't set in registry
result = get_data_dir(repo)
assert result == repo / ".code-review-graph"
assert result.is_dir()
def test_data_dir_auto_creates_directory(self, tmp_path, monkeypatch):
"""get_data_dir should auto-create the data directory."""
from code_review_graph.incremental import get_data_dir
from code_review_graph.registry import Registry
repo = tmp_path / "project"
repo.mkdir()
data_dir = tmp_path / "nonexistent" / "nested" / "path"
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
registry = Registry()
registry.set_data_dir(str(repo), str(data_dir))
result = get_data_dir(repo)
assert result.exists()
assert result.is_dir()
assert result == data_dir.resolve()
class TestIsBinary:
def test_text_file_is_not_binary(self, tmp_path):
f = tmp_path / "text.py"
f.write_text("print('hello')\n")
assert not _is_binary(f)
def test_binary_file_is_binary(self, tmp_path):
f = tmp_path / "binary.bin"
f.write_bytes(b"header\x00binary data")
assert _is_binary(f)
def test_missing_file_is_binary(self, tmp_path):
f = tmp_path / "missing.txt"
assert _is_binary(f)
class TestGitOperations:
@patch("code_review_graph.incremental.subprocess.run")
def test_get_changed_files(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(
returncode=0,
stdout="src/a.py\nsrc/b.py\n",
)
result = get_changed_files(tmp_path)
assert result == ["src/a.py", "src/b.py"]
mock_run.assert_called_once()
call_args = mock_run.call_args
assert "git" in call_args[0][0]
assert call_args[1].get("timeout") == 30
@patch("code_review_graph.incremental.subprocess.run")
def test_get_changed_files_fallback(self, mock_run, tmp_path):
# First call fails, second succeeds
mock_run.side_effect = [
MagicMock(returncode=1, stdout=""),
MagicMock(returncode=0, stdout="staged.py\n"),
]
result = get_changed_files(tmp_path)
assert result == ["staged.py"]
assert mock_run.call_count == 2
@patch("code_review_graph.incremental.subprocess.run")
def test_get_changed_files_timeout(self, mock_run, tmp_path):
mock_run.side_effect = subprocess.TimeoutExpired("git", 30)
result = get_changed_files(tmp_path)
assert result == []
@patch("code_review_graph.incremental.subprocess.run")
def test_get_staged_and_unstaged(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(
returncode=0,
stdout=" M src/a.py\n?? new.py\nR old.py -> new_name.py\n",
)
result = get_staged_and_unstaged(tmp_path)
assert "src/a.py" in result
assert "new.py" in result
assert "new_name.py" in result
# old.py should NOT be in results (renamed away)
assert "old.py" not in result
@patch("code_review_graph.incremental.subprocess.run")
def test_get_all_tracked_files(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(
returncode=0,
stdout="a.py\nb.py\nc.go\n",
)
result = get_all_tracked_files(tmp_path)
assert result == ["a.py", "b.py", "c.go"]
@patch("code_review_graph.incremental.subprocess.run")
def test_get_all_tracked_files_recurse_submodules_param(
self, mock_run, tmp_path
):
mock_run.return_value = MagicMock(
returncode=0,
stdout="a.py\nsub/b.py\n",
)
result = get_all_tracked_files(tmp_path, recurse_submodules=True)
assert result == ["a.py", "sub/b.py"]
cmd = mock_run.call_args[0][0]
assert "--recurse-submodules" in cmd
@patch("code_review_graph.incremental.subprocess.run")
def test_get_all_tracked_files_no_recurse_by_default(
self, mock_run, tmp_path
):
mock_run.return_value = MagicMock(
returncode=0,
stdout="a.py\n",
)
result = get_all_tracked_files(tmp_path)
assert result == ["a.py"]
cmd = mock_run.call_args[0][0]
assert "--recurse-submodules" not in cmd
@patch("code_review_graph.incremental.subprocess.run")
@patch("code_review_graph.incremental._RECURSE_SUBMODULES", True)
def test_get_all_tracked_files_env_var_fallback(
self, mock_run, tmp_path
):
mock_run.return_value = MagicMock(
returncode=0,
stdout="a.py\nsub/c.py\n",
)
# None -> falls back to env var (_RECURSE_SUBMODULES=True)
result = get_all_tracked_files(tmp_path, recurse_submodules=None)
assert result == ["a.py", "sub/c.py"]
cmd = mock_run.call_args[0][0]
assert "--recurse-submodules" in cmd
@patch("code_review_graph.incremental.subprocess.run")
@patch("code_review_graph.incremental._RECURSE_SUBMODULES", True)
def test_get_all_tracked_files_param_overrides_env(
self, mock_run, tmp_path
):
mock_run.return_value = MagicMock(
returncode=0,
stdout="a.py\n",
)
# Explicit False overrides env var
result = get_all_tracked_files(tmp_path, recurse_submodules=False)
assert result == ["a.py"]
cmd = mock_run.call_args[0][0]
assert "--recurse-submodules" not in cmd
class TestFullBuild:
def test_full_build_parses_files(self, tmp_path):
# Create a simple Python file
py_file = tmp_path / "sample.py"
py_file.write_text("def hello():\n pass\n")
(tmp_path / ".git").mkdir()
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
try:
mock_target = "code_review_graph.incremental.get_all_tracked_files"
with patch(mock_target, return_value=["sample.py"]):
result = full_build(tmp_path, store)
assert result["files_parsed"] == 1
assert result["total_nodes"] > 0
assert result["errors"] == []
assert store.get_metadata("last_build_type") == "full"
finally:
store.close()
class TestIncrementalUpdate:
def test_incremental_with_no_changes(self, tmp_path):
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
try:
result = incremental_update(tmp_path, store, changed_files=[])
assert result["files_updated"] == 0
finally:
store.close()
def test_incremental_with_changed_file(self, tmp_path):
py_file = tmp_path / "mod.py"
py_file.write_text("def greet():\n return 'hi'\n")
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
try:
result = incremental_update(
tmp_path, store, changed_files=["mod.py"]
)
assert result["files_updated"] >= 1
assert result["total_nodes"] > 0
finally:
store.close()
def test_incremental_deleted_file(self, tmp_path):
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
try:
# Pre-populate with a file
py_file = tmp_path / "old.py"
py_file.write_text("x = 1\n")
result = incremental_update(tmp_path, store, changed_files=["old.py"])
assert result["total_nodes"] > 0
# Now delete the file and run incremental
py_file.unlink()
incremental_update(tmp_path, store, changed_files=["old.py"])
# File should have been removed from graph
nodes = store.get_nodes_by_file(str(tmp_path / "old.py"))
assert len(nodes) == 0
finally:
store.close()
class TestParallelParsing:
def test_parse_single_file(self, tmp_path):
py_file = tmp_path / "single.py"
py_file.write_text("def foo():\n pass\n")
rel_path, nodes, edges, error, fhash = _parse_single_file(
("single.py", str(tmp_path))
)
assert rel_path == "single.py"
assert error is None
assert len(nodes) > 0
assert fhash != ""
def test_parse_single_file_missing(self, tmp_path):
rel_path, nodes, edges, error, fhash = _parse_single_file(
("missing.py", str(tmp_path))
)
assert error is not None
assert nodes == []
assert edges == []
def test_parallel_build_produces_same_results(self, tmp_path):
"""Serial and parallel builds produce identical node/edge counts."""
(tmp_path / ".git").mkdir()
# Create several Python files
for i in range(10):
(tmp_path / f"mod{i}.py").write_text(
f"def func_{i}():\n return {i}\n\n"
f"class Cls{i}:\n pass\n"
)
tracked = [f"mod{i}.py" for i in range(10)]
mock_target = "code_review_graph.incremental.get_all_tracked_files"
# Serial build
db_serial = tmp_path / "serial.db"
store_serial = GraphStore(db_serial)
try:
with patch(mock_target, return_value=tracked):
with patch.dict("os.environ", {"CRG_SERIAL_PARSE": "1"}):
result_serial = full_build(tmp_path, store_serial)
serial_nodes = result_serial["total_nodes"]
serial_edges = result_serial["total_edges"]
serial_files = result_serial["files_parsed"]
finally:
store_serial.close()
# Parallel build
db_parallel = tmp_path / "parallel.db"
store_parallel = GraphStore(db_parallel)
try:
with patch(mock_target, return_value=tracked):
with patch.dict("os.environ", {"CRG_SERIAL_PARSE": ""}):
result_parallel = full_build(tmp_path, store_parallel)
parallel_nodes = result_parallel["total_nodes"]
parallel_edges = result_parallel["total_edges"]
parallel_files = result_parallel["files_parsed"]
finally:
store_parallel.close()
assert serial_files == parallel_files
assert serial_nodes == parallel_nodes
assert serial_edges == parallel_edges
class TestMultiHopDependents:
"""Tests for N-hop dependent discovery."""
def _make_chain_store(self, tmp_path):
"""Build A -> B -> C chain in the graph."""
from code_review_graph.parser import EdgeInfo, NodeInfo
db_path = tmp_path / "chain.db"
store = GraphStore(db_path)
for name, path in [("a", "/a.py"), ("b", "/b.py"), ("c", "/c.py")]:
store.upsert_node(NodeInfo(
kind="File", name=path, file_path=path,
line_start=1, line_end=10, language="python",
))
store.upsert_node(NodeInfo(
kind="Function", name=f"func_{name}", file_path=path,
line_start=2, line_end=8, language="python",
))
# A imports B, B imports C
store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source="/a.py::func_a",
target="/b.py::func_b", file_path="/a.py", line=1,
))
store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source="/b.py::func_b",
target="/c.py::func_c", file_path="/b.py", line=1,
))
store.commit()
return store
def test_single_hop_finds_direct_only(self, tmp_path):
store = self._make_chain_store(tmp_path)
try:
deps = _single_hop_dependents(store, "/c.py")
assert "/b.py" in deps
assert "/a.py" not in deps
finally:
store.close()
def test_one_hop_finds_b_not_a(self, tmp_path):
store = self._make_chain_store(tmp_path)
try:
deps = find_dependents(store, "/c.py", max_hops=1)
assert "/b.py" in deps
assert "/a.py" not in deps
finally:
store.close()
def test_two_hops_finds_b_and_a(self, tmp_path):
store = self._make_chain_store(tmp_path)
try:
deps = find_dependents(store, "/c.py", max_hops=2)
assert "/b.py" in deps
assert "/a.py" in deps
finally:
store.close()
def test_cap_triggers_on_many_files(self, tmp_path):
"""The 500-file cap prevents runaway expansion."""
from code_review_graph.parser import EdgeInfo, NodeInfo
db_path = tmp_path / "big.db"
store = GraphStore(db_path)
try:
# Hub node that many files depend on
store.upsert_node(NodeInfo(
kind="File", name="/hub.py", file_path="/hub.py",
line_start=1, line_end=10, language="python",
))
store.upsert_node(NodeInfo(
kind="Function", name="hub_func", file_path="/hub.py",
line_start=2, line_end=8, language="python",
))
for i in range(600):
path = f"/dep{i}.py"
store.upsert_node(NodeInfo(
kind="File", name=path, file_path=path,
line_start=1, line_end=10, language="python",
))
store.upsert_node(NodeInfo(
kind="Function", name=f"func_{i}", file_path=path,
line_start=2, line_end=8, language="python",
))
store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source=f"{path}::func_{i}",
target="/hub.py::hub_func", file_path=path, line=1,
))
store.commit()
# Even with high max_hops, cap should limit results
deps = find_dependents(store, "/hub.py", max_hops=5)
assert len(deps) <= 500
finally:
store.close()
def test_truncated_flag_set_when_capped(self, tmp_path):
"""Regression test for #261: find_dependents must set
DependentList.truncated = True when the result is capped."""
from code_review_graph.parser import EdgeInfo, NodeInfo
db_path = tmp_path / "trunc.db"
store = GraphStore(db_path)
try:
store.upsert_node(NodeInfo(
kind="File", name="/hub.py", file_path="/hub.py",
line_start=1, line_end=10, language="python",
))
store.upsert_node(NodeInfo(
kind="Function", name="hub_func", file_path="/hub.py",
line_start=2, line_end=8, language="python",
))
for i in range(600):
path = f"/dep{i}.py"
store.upsert_node(NodeInfo(
kind="File", name=path, file_path=path,
line_start=1, line_end=10, language="python",
))
store.upsert_node(NodeInfo(
kind="Function", name=f"func_{i}", file_path=path,
line_start=2, line_end=8, language="python",
))
store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source=f"{path}::func_{i}",
target="/hub.py::hub_func", file_path=path, line=1,
))
store.commit()
deps = find_dependents(store, "/hub.py", max_hops=5)
assert len(deps) <= 500
# The key assertion: truncated flag must be set.
assert deps.truncated is True, (
"DependentList.truncated should be True when capped at "
"_MAX_DEPENDENT_FILES, but it was False"
)
finally:
store.close()
def test_truncated_flag_false_when_not_capped(self, tmp_path):
"""Regression test for #261: find_dependents must set
DependentList.truncated = False when the result is complete."""
store = self._make_chain_store(tmp_path)
try:
deps = find_dependents(store, "/c.py", max_hops=2)
assert deps.truncated is False, (
"DependentList.truncated should be False when the "
"expansion completed without hitting the cap"
)
finally:
store.close()
class TestStartWatchThread:
@patch("code_review_graph.incremental.watch")
def test_starts_background_thread(self, mock_watch, tmp_path):
"""start_watch_thread returns a running thread when watchdog is available."""
import threading
barrier = threading.Event()
mock_watch.side_effect = lambda *a, **kw: barrier.wait(timeout=5)
db_path = tmp_path / "graph.db"
store = GraphStore(db_path)
try:
thread = start_watch_thread(tmp_path, store, daemon=True)
assert thread is not None
assert thread.daemon is True
assert thread.is_alive()
finally:
barrier.set()
store.close()
def test_returns_none_when_watchdog_unavailable(self, tmp_path):
"""start_watch_thread returns None when watchdog is not installed."""
db_path = tmp_path / "graph.db"
store = GraphStore(db_path)
try:
with patch.dict("sys.modules", {"watchdog": None}):
thread = start_watch_thread(tmp_path, store, daemon=True)
assert thread is None
finally:
store.close()
+253
View File
@@ -0,0 +1,253 @@
"""Integration tests exercising git-dependent code with real temporary repos.
Tests cover:
- get_changed_files with real git history
- parse_git_diff_ranges with real diffs
- incremental_update detecting real file modifications
- base ref injection rejection
- wiki page path traversal protection
"""
from __future__ import annotations
import subprocess
import tempfile
from pathlib import Path
import pytest
from code_review_graph.changes import parse_git_diff_ranges
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import (
collect_all_files,
full_build,
get_all_tracked_files,
get_changed_files,
incremental_update,
)
from code_review_graph.wiki import get_wiki_page
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
"""Run a git command inside *repo* and return the result."""
return subprocess.run(
["git", *args],
capture_output=True,
text=True,
cwd=str(repo),
timeout=10,
)
@pytest.fixture()
def git_repo(tmp_path: Path) -> Path:
"""Create a real git repo with two commits.
Commit 1 adds ``hello.py`` with a single function.
Commit 2 modifies ``hello.py`` (adds a second function).
"""
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init")
_git(repo, "config", "user.email", "test@test.com")
_git(repo, "config", "user.name", "Test")
# First commit
py_file = repo / "hello.py"
py_file.write_text("def greet():\n return 'hello'\n")
_git(repo, "add", "hello.py")
_git(repo, "commit", "-m", "initial commit")
# Second commit — modify the file
py_file.write_text(
"def greet():\n return 'hello'\n\n"
"def farewell():\n return 'goodbye'\n"
)
_git(repo, "add", "hello.py")
_git(repo, "commit", "-m", "add farewell function")
return repo
# ------------------------------------------------------------------
# 1. get_changed_files with a real git repo
# ------------------------------------------------------------------
def test_get_changed_files_real_git(git_repo: Path) -> None:
"""get_changed_files should list hello.py as changed between HEAD~1..HEAD."""
changed = get_changed_files(git_repo, base="HEAD~1")
assert "hello.py" in changed
# ------------------------------------------------------------------
# 2. parse_git_diff_ranges with a real git repo
# ------------------------------------------------------------------
def test_parse_git_diff_ranges_real_git(git_repo: Path) -> None:
"""parse_git_diff_ranges should return non-empty line ranges for hello.py."""
ranges = parse_git_diff_ranges(str(git_repo), base="HEAD~1")
assert "hello.py" in ranges
assert len(ranges["hello.py"]) > 0
# Each entry is a (start, end) tuple with positive line numbers
for start, end in ranges["hello.py"]:
assert start >= 1
assert end >= start
# ------------------------------------------------------------------
# 3. incremental_update detects real modifications
# ------------------------------------------------------------------
def test_incremental_update_real_git(git_repo: Path) -> None:
"""Full build then incremental update should detect the second commit."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
store = GraphStore(db_path)
# Reset to first commit, do a full build
_git(git_repo, "checkout", "HEAD~1", "--detach")
full_build(git_repo, store)
initial_nodes = store.get_stats().total_nodes
assert initial_nodes > 0, "full_build should create at least one node"
# Move back to tip (second commit) and do incremental update
_git(git_repo, "checkout", "-")
result = incremental_update(
git_repo, store, changed_files=["hello.py"]
)
assert result["files_updated"] >= 1
assert "hello.py" in result["changed_files"]
# The graph should now contain more nodes (farewell function added)
assert store.get_stats().total_nodes >= initial_nodes
store.close()
finally:
Path(db_path).unlink(missing_ok=True)
# ------------------------------------------------------------------
# 4. base ref injection is rejected
# ------------------------------------------------------------------
def test_base_validation_rejects_injection(git_repo: Path) -> None:
"""Passing a malicious --flag as base should be rejected (empty list)."""
result = get_changed_files(git_repo, base="--output=/tmp/evil")
assert result == []
# ------------------------------------------------------------------
# 5. wiki page path traversal is blocked
# ------------------------------------------------------------------
@pytest.fixture()
def git_repo_with_submodule(tmp_path: Path) -> Path:
"""Create a parent repo containing a git submodule with a Python file."""
# Create the "library" repo that will become a submodule
lib_repo = tmp_path / "lib"
lib_repo.mkdir()
_git(lib_repo, "init")
_git(lib_repo, "config", "user.email", "test@test.com")
_git(lib_repo, "config", "user.name", "Test")
(lib_repo / "util.py").write_text("def helper():\n pass\n")
_git(lib_repo, "add", "util.py")
_git(lib_repo, "commit", "-m", "lib initial")
# Create the parent repo and add lib as a submodule
parent = tmp_path / "parent"
parent.mkdir()
_git(parent, "init")
_git(parent, "config", "user.email", "test@test.com")
_git(parent, "config", "user.name", "Test")
(parent / "main.py").write_text("def main():\n pass\n")
_git(parent, "add", "main.py")
_git(parent, "commit", "-m", "parent initial")
_git(
parent, "-c", "protocol.file.allow=always",
"submodule", "add", str(lib_repo), "lib",
)
_git(parent, "commit", "-m", "add lib submodule")
return parent
def test_get_all_tracked_files_without_recurse(
git_repo_with_submodule: Path,
) -> None:
"""Without recurse_submodules, submodule files are NOT listed."""
files = get_all_tracked_files(
git_repo_with_submodule, recurse_submodules=False
)
assert "main.py" in files
# Submodule entry appears as a gitlink, not as individual files
assert not any(f.startswith("lib/") for f in files)
def test_get_all_tracked_files_with_recurse(
git_repo_with_submodule: Path,
) -> None:
"""With recurse_submodules=True, submodule files ARE listed."""
files = get_all_tracked_files(
git_repo_with_submodule, recurse_submodules=True
)
assert "main.py" in files
assert "lib/util.py" in files
def test_collect_all_files_with_recurse(
git_repo_with_submodule: Path,
) -> None:
"""collect_all_files with recurse_submodules includes submodule code."""
files = collect_all_files(
git_repo_with_submodule, recurse_submodules=True
)
assert "main.py" in files
assert "lib/util.py" in files
def test_full_build_with_recurse_submodules(
git_repo_with_submodule: Path,
) -> None:
"""full_build with recurse_submodules parses submodule files."""
db_path = git_repo_with_submodule / ".code-review-graph" / "graph.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
store = GraphStore(db_path)
try:
result = full_build(
git_repo_with_submodule, store, recurse_submodules=True
)
assert result["files_parsed"] >= 2 # main.py + lib/util.py
assert result["errors"] == []
# Verify both parent and submodule nodes exist
parent_nodes = store.get_nodes_by_file(
str(git_repo_with_submodule / "main.py")
)
sub_nodes = store.get_nodes_by_file(
str(git_repo_with_submodule / "lib" / "util.py")
)
assert len(parent_nodes) > 0
assert len(sub_nodes) > 0
finally:
store.close()
def test_wiki_page_path_traversal_blocked(tmp_path: Path) -> None:
"""get_wiki_page must not serve files outside the wiki directory."""
wiki_dir = tmp_path / "wiki"
wiki_dir.mkdir()
# Create a legitimate page
(wiki_dir / "my-module.md").write_text("# My Module\n")
# Attempt a path traversal — should return None
result = get_wiki_page(str(wiki_dir), "../../etc/passwd")
assert result is None
+423
View File
@@ -0,0 +1,423 @@
"""Comprehensive end-to-end integration test for the v2 pipeline.
Exercises: flows, communities, FTS search, analyze_changes,
find_dead_code, rename_preview, generate_hints, review_changes_prompt,
generate_wiki, and the Registry API.
"""
import tempfile
from pathlib import Path
from code_review_graph.changes import analyze_changes
from code_review_graph.communities import (
detect_communities,
get_architecture_overview,
get_communities,
store_communities,
)
from code_review_graph.flows import (
get_affected_flows,
get_flow_by_id,
get_flows,
store_flows,
trace_flows,
)
from code_review_graph.graph import GraphStore
from code_review_graph.hints import generate_hints, get_session, reset_session
from code_review_graph.parser import EdgeInfo, NodeInfo
from code_review_graph.prompts import review_changes_prompt
from code_review_graph.refactor import find_dead_code, rename_preview
from code_review_graph.registry import Registry
from code_review_graph.search import hybrid_search, rebuild_fts_index
from code_review_graph.wiki import generate_wiki
class TestV2Integration:
"""End-to-end integration test exercising the full v2 pipeline."""
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self._seed_realistic_graph()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
# -----------------------------------------------------------------
# Graph seeding helpers
# -----------------------------------------------------------------
def _seed_realistic_graph(self):
"""Seed a realistic multi-file graph with auth, db, and API layers."""
s = self.store
# --- auth.py: authentication module ---
s.upsert_node(NodeInfo(
kind="File", name="auth.py", file_path="auth.py",
line_start=1, line_end=100, language="python",
), file_hash="a1")
s.upsert_node(NodeInfo(
kind="Function", name="login", file_path="auth.py",
line_start=5, line_end=20, language="python",
params="(username: str, password: str)", return_type="Token",
extra={"decorators": ["route"]},
), file_hash="a1")
s.upsert_node(NodeInfo(
kind="Function", name="logout", file_path="auth.py",
line_start=25, line_end=40, language="python",
params="(token: str)", return_type="bool",
extra={"decorators": ["route"]},
), file_hash="a1")
s.upsert_node(NodeInfo(
kind="Function", name="verify_token", file_path="auth.py",
line_start=45, line_end=60, language="python",
params="(token: str)", return_type="bool",
), file_hash="a1")
# --- db.py: database layer ---
s.upsert_node(NodeInfo(
kind="File", name="db.py", file_path="db.py",
line_start=1, line_end=120, language="python",
), file_hash="b1")
s.upsert_node(NodeInfo(
kind="Class", name="Database", file_path="db.py",
line_start=5, line_end=60, language="python",
), file_hash="b1")
s.upsert_node(NodeInfo(
kind="Function", name="connect", file_path="db.py",
line_start=10, line_end=25, language="python",
parent_name="Database",
params="(self, dsn: str)", return_type="Connection",
), file_hash="b1")
s.upsert_node(NodeInfo(
kind="Function", name="query", file_path="db.py",
line_start=30, line_end=50, language="python",
parent_name="Database",
params="(self, sql: str)", return_type="list[Row]",
), file_hash="b1")
s.upsert_node(NodeInfo(
kind="Function", name="close", file_path="db.py",
line_start=55, line_end=60, language="python",
parent_name="Database",
params="(self)", return_type="None",
), file_hash="b1")
# --- api.py: API handlers ---
s.upsert_node(NodeInfo(
kind="File", name="api.py", file_path="api.py",
line_start=1, line_end=80, language="python",
), file_hash="c1")
s.upsert_node(NodeInfo(
kind="Function", name="get_users", file_path="api.py",
line_start=5, line_end=20, language="python",
params="(request: Request)", return_type="Response",
extra={"decorators": ["route"]},
), file_hash="c1")
s.upsert_node(NodeInfo(
kind="Function", name="create_user", file_path="api.py",
line_start=25, line_end=45, language="python",
params="(request: Request)", return_type="Response",
extra={"decorators": ["route"]},
), file_hash="c1")
# --- utils.py: orphaned helper (dead code candidate) ---
s.upsert_node(NodeInfo(
kind="File", name="utils.py", file_path="utils.py",
line_start=1, line_end=30, language="python",
), file_hash="d1")
s.upsert_node(NodeInfo(
kind="Function", name="format_date", file_path="utils.py",
line_start=5, line_end=15, language="python",
params="(dt: datetime)", return_type="str",
), file_hash="d1")
# --- test_auth.py: tests ---
s.upsert_node(NodeInfo(
kind="File", name="test_auth.py", file_path="test_auth.py",
line_start=1, line_end=40, language="python",
), file_hash="e1")
s.upsert_node(NodeInfo(
kind="Test", name="test_login", file_path="test_auth.py",
line_start=5, line_end=15, language="python",
is_test=True,
), file_hash="e1")
# --- Edges: calls ---
call_edges = [
("auth.py::login", "auth.py::verify_token", "auth.py", 10),
("auth.py::logout", "auth.py::verify_token", "auth.py", 30),
("api.py::get_users", "db.py::Database.query", "api.py", 10),
("api.py::get_users", "auth.py::verify_token", "api.py", 8),
("api.py::create_user", "db.py::Database.query", "api.py", 30),
("api.py::create_user", "auth.py::verify_token", "api.py", 28),
("db.py::Database.query", "db.py::Database.connect", "db.py", 35),
]
for source, target, fp, ln in call_edges:
s.upsert_edge(EdgeInfo(
kind="CALLS", source=source, target=target,
file_path=fp, line=ln,
))
# --- Edges: contains ---
contains_edges = [
("auth.py", "auth.py::login", "auth.py"),
("auth.py", "auth.py::logout", "auth.py"),
("auth.py", "auth.py::verify_token", "auth.py"),
("db.py", "db.py::Database", "db.py"),
("db.py::Database", "db.py::Database.connect", "db.py"),
("db.py::Database", "db.py::Database.query", "db.py"),
("db.py::Database", "db.py::Database.close", "db.py"),
("api.py", "api.py::get_users", "api.py"),
("api.py", "api.py::create_user", "api.py"),
("utils.py", "utils.py::format_date", "utils.py"),
]
for source, target, fp in contains_edges:
s.upsert_edge(EdgeInfo(
kind="CONTAINS", source=source, target=target,
file_path=fp, line=1,
))
# --- Edges: tested_by ---
s.upsert_edge(EdgeInfo(
kind="TESTED_BY", source="test_auth.py::test_login",
target="auth.py::login", file_path="test_auth.py", line=5,
))
s.commit()
# Set signatures for non-File nodes
rows = s._conn.execute(
"SELECT id, name, kind, params, return_type FROM nodes"
).fetchall()
for row in rows:
node_id, name, kind, params, ret = row[0], row[1], row[2], row[3], row[4]
if kind in ("Function", "Test"):
sig = f"def {name}({params or ''})"
if ret:
sig += f" -> {ret}"
elif kind == "Class":
sig = f"class {name}"
else:
sig = name
s._conn.execute(
"UPDATE nodes SET signature = ? WHERE id = ?",
(sig[:512], node_id),
)
s._conn.commit()
# -----------------------------------------------------------------
# Integration test
# -----------------------------------------------------------------
def test_full_pipeline(self):
"""Exercise the full v2 pipeline end-to-end."""
# ---- Step 1: Verify graph data was seeded correctly ----
stats = self.store.get_stats()
assert stats.total_nodes >= 12, f"Expected >= 12 nodes, got {stats.total_nodes}"
assert stats.total_edges >= 10, f"Expected >= 10 edges, got {stats.total_edges}"
# ---- Step 2: trace_flows + store_flows ----
flows = trace_flows(self.store)
assert isinstance(flows, list)
assert len(flows) > 0, "Should detect at least one flow"
flow_count = store_flows(self.store, flows)
assert flow_count == len(flows)
# Verify retrieval
stored = get_flows(self.store, limit=50)
assert len(stored) > 0
# Verify single flow retrieval
first_flow = stored[0]
detail = get_flow_by_id(self.store, first_flow["id"])
assert detail is not None
assert "steps" in detail
# ---- Step 3: detect_communities + store_communities ----
communities = detect_communities(self.store)
assert isinstance(communities, list)
assert len(communities) > 0, "Should detect at least one community"
comm_count = store_communities(self.store, communities)
assert comm_count == len(communities)
# Verify retrieval
stored_comms = get_communities(self.store)
assert len(stored_comms) > 0
# Each community should have name and size
for comm in stored_comms:
assert "name" in comm
assert "size" in comm
assert comm["size"] > 0
# Architecture overview
arch = get_architecture_overview(self.store)
assert "communities" in arch
assert "cross_community_edges" in arch
# ---- Step 4: rebuild_fts_index + hybrid_search ----
fts_count = rebuild_fts_index(self.store)
assert fts_count > 0, "FTS should index at least some nodes"
# Search for known functions
results = hybrid_search(self.store, "login")
assert len(results) > 0, "hybrid_search should find 'login'"
names = [r["name"] for r in results]
assert any("login" in n for n in names)
# Search by kind
results_func = hybrid_search(self.store, "query", kind="Function")
assert len(results_func) > 0
# ---- Step 5: analyze_changes ----
change_result = analyze_changes(
self.store,
changed_files=["auth.py"],
changed_ranges=None,
repo_root=None,
base="HEAD~1",
)
assert "summary" in change_result
assert "risk_score" in change_result
assert "changed_functions" in change_result
assert "test_gaps" in change_result
assert isinstance(change_result["risk_score"], (int, float))
# auth.py has verify_token, logout -- logout should be a test gap
# (login has a TESTED_BY edge)
gap_names = [g["name"] for g in change_result["test_gaps"]]
assert "verify_token" in gap_names or "logout" in gap_names, (
f"Expected at least one test gap in auth.py, got: {gap_names}"
)
# ---- Step 6: find_dead_code ----
dead = find_dead_code(self.store)
assert isinstance(dead, list)
dead_names = [d["name"] for d in dead]
# format_date has no callers, no tests, no importers -- should be dead
assert "format_date" in dead_names, (
f"format_date should be dead code, got: {dead_names}"
)
# ---- Step 7: rename_preview ----
preview = rename_preview(self.store, "verify_token", "validate_token")
assert preview is not None, "rename_preview should find verify_token"
assert "edits" in preview
assert len(preview["edits"]) > 0
# Should include definition + call sites
edit_files = {e["file"] for e in preview["edits"]}
assert "auth.py" in edit_files
# ---- Step 8: generate_hints ----
reset_session()
session = get_session()
hints = generate_hints(
"detect_changes",
change_result,
session,
)
assert "next_steps" in hints
assert "warnings" in hints
assert isinstance(hints["next_steps"], list)
# ---- Step 9: review_changes_prompt ----
prompt_messages = review_changes_prompt(base="HEAD~1")
assert isinstance(prompt_messages, list)
assert len(prompt_messages) > 0
assert prompt_messages[0].role == "user"
assert "detect_changes" in prompt_messages[0].content.text
# ---- Step 10: generate_wiki ----
with tempfile.TemporaryDirectory() as wiki_dir:
wiki_result = generate_wiki(self.store, wiki_dir, force=True)
assert "pages_generated" in wiki_result
assert "pages_updated" in wiki_result
assert "pages_unchanged" in wiki_result
total = (
wiki_result["pages_generated"]
+ wiki_result["pages_updated"]
+ wiki_result["pages_unchanged"]
)
# At least one community page should have been generated
assert total >= 0 # might be 0 if no communities stored
if stored_comms:
assert total > 0, "Wiki should generate pages for communities"
# Verify index file exists
index_path = Path(wiki_dir) / "index.md"
assert index_path.exists(), "Wiki should generate index.md"
# ---- Step 11: Registry (basic API test) ----
with tempfile.TemporaryDirectory() as reg_dir:
reg_path = Path(reg_dir) / "registry.json"
registry = Registry(path=reg_path)
# Empty initially
assert registry.list_repos() == []
# Register a fake repo (create .git dir so validation passes)
fake_repo = Path(reg_dir) / "my-project"
fake_repo.mkdir()
(fake_repo / ".git").mkdir()
entry = registry.register(str(fake_repo), alias="myproj")
assert entry["alias"] == "myproj"
assert str(fake_repo.resolve()) in entry["path"]
repos = registry.list_repos()
assert len(repos) == 1
# Unregister
assert registry.unregister("myproj") is True
assert registry.list_repos() == []
def test_affected_flows_with_changed_files(self):
"""get_affected_flows should identify flows touching changed files."""
# Must have flows stored first
flows = trace_flows(self.store)
store_flows(self.store, flows)
affected = get_affected_flows(self.store, changed_files=["auth.py"])
assert "affected_flows" in affected
assert "total" in affected
# auth.py contains login/logout/verify_token -- flows through them
# should be detected
assert affected["total"] >= 0 # May be 0 if no flow touches auth.py
def test_pipeline_idempotent(self):
"""Running the pipeline twice yields consistent results."""
# First run
flows1 = trace_flows(self.store)
store_flows(self.store, flows1)
comms1 = detect_communities(self.store)
store_communities(self.store, comms1)
fts1 = rebuild_fts_index(self.store)
# Second run (should overwrite cleanly)
flows2 = trace_flows(self.store)
store_flows(self.store, flows2)
comms2 = detect_communities(self.store)
store_communities(self.store, comms2)
fts2 = rebuild_fts_index(self.store)
assert len(flows1) == len(flows2)
assert len(comms1) == len(comms2)
assert fts1 == fts2
def test_search_after_rebuild(self):
"""FTS search works correctly after index rebuild."""
rebuild_fts_index(self.store)
# Exact function name
results = hybrid_search(self.store, "create_user")
assert any(r["name"] == "create_user" for r in results)
# Class name
results = hybrid_search(self.store, "Database")
assert any(r["name"] == "Database" for r in results)
# Partial match
results = hybrid_search(self.store, "user")
names = [r["name"] for r in results]
assert any("user" in n.lower() for n in names)
+329
View File
@@ -0,0 +1,329 @@
"""Tests for the MCP server entry point.
Focused on the ``_resolve_repo_root`` helper that threads the
``serve --repo <X>`` CLI flag into every tool wrapper, and on the
set of tools that must be registered as async coroutines so the MCP
stdio event loop stays responsive during long-running operations.
"""
from __future__ import annotations
import asyncio
import inspect
import pytest
from code_review_graph import main as crg_main
@pytest.fixture(autouse=True)
def _isolate_crg_tools_env(monkeypatch):
"""Always strip CRG_TOOLS so that any test invoking ``crg_main.main``
does not accidentally permanently shrink the global tool registry
when the suite runs under a developer environment that exports
``CRG_TOOLS``. Without this the snapshot/restore in
``TestApplyToolFilter._restore_tools`` only sees the already-filtered
set and cannot restore the dropped tools."""
monkeypatch.delenv("CRG_TOOLS", raising=False)
class TestResolveRepoRoot:
"""Precedence rules for _resolve_repo_root (see #222 follow-up)."""
@pytest.fixture(autouse=True)
def _reset_default(self):
"""Save and restore the module-level default before/after each test."""
original = crg_main._default_repo_root
yield
crg_main._default_repo_root = original
def test_none_when_neither_is_set(self):
crg_main._default_repo_root = None
assert crg_main._resolve_repo_root(None) is None
def test_empty_string_treated_as_unset(self):
"""Empty string from an MCP client should not shadow the --repo flag."""
crg_main._default_repo_root = "/tmp/flag-repo"
assert crg_main._resolve_repo_root("") == "/tmp/flag-repo"
def test_flag_used_when_client_omits_repo_root(self):
crg_main._default_repo_root = "/tmp/flag-repo"
assert crg_main._resolve_repo_root(None) == "/tmp/flag-repo"
def test_client_arg_wins_over_flag(self):
crg_main._default_repo_root = "/tmp/flag-repo"
assert crg_main._resolve_repo_root("/explicit") == "/explicit"
def test_client_arg_used_when_no_flag(self):
crg_main._default_repo_root = None
assert crg_main._resolve_repo_root("/explicit") == "/explicit"
class TestServeMainTransport:
"""``main()`` wires FastMCP to stdio or Streamable HTTP."""
def test_stdio_calls_mcp_run_stdio(self, monkeypatch):
calls: list[dict] = []
def fake_run(**kwargs):
calls.append(kwargs)
monkeypatch.setattr(crg_main.mcp, "run", fake_run)
crg_main.main(repo_root=None)
assert calls == [{"transport": "stdio", "show_banner": False}]
def test_http_calls_mcp_run_with_host_port(self, monkeypatch):
calls: list[dict] = []
def fake_run(**kwargs):
calls.append(kwargs)
monkeypatch.setattr(crg_main.mcp, "run", fake_run)
crg_main.main(
repo_root="/tmp/r",
transport="streamable-http",
host="127.0.0.1",
port=5555,
)
assert calls == [
{
"transport": "streamable-http",
"host": "127.0.0.1",
"port": 5555,
}
]
def test_streamable_http_without_host_port_raises(self):
with pytest.raises(ValueError, match="requires host and port"):
crg_main.main(transport="streamable-http", host=None, port=5555)
with pytest.raises(ValueError, match="requires host and port"):
crg_main.main(transport="streamable-http", host="127.0.0.1", port=None)
class TestLongRunningToolsAreAsync:
"""Long-running MCP tools must be registered as coroutines so the
asyncio event loop stays responsive while the work runs in a
background thread via ``asyncio.to_thread``. Without this, Windows
MCP clients hang on ``build_or_update_graph_tool`` and
``embed_graph_tool`` — see #46, #136.
"""
HEAVY_TOOLS = {
"build_or_update_graph_tool",
"run_postprocess_tool",
"embed_graph_tool",
"detect_changes_tool",
"generate_wiki_tool",
}
def test_heavy_tools_are_coroutines(self):
"""Regression guard for #46/#136: the 5 long-running MCP tools must
stay ``async def`` so FastMCP can offload their blocking work via
``asyncio.to_thread`` and keep the stdio event loop responsive.
The original implementation of this test went through
``crg_main.mcp.get_tools()``, which does not exist in the FastMCP
2.14+ API pinned in pyproject.toml (``list_tools()`` replaces it and
returns MCP protocol ``Tool`` objects, which do not expose the
underlying Python function at all). The sibling test
``test_heavy_tool_source_uses_to_thread`` already resolves each
tool by ``getattr(crg_main, name)``; we do the same here so this
guard is independent of any FastMCP internal surface. See #239.
"""
missing: list[str] = []
not_async: list[str] = []
for tool_name in self.HEAVY_TOOLS:
fn = getattr(crg_main, tool_name, None)
if fn is None:
missing.append(tool_name)
continue
# The @mcp.tool() decorator wraps the function; FunctionTool
# stores the underlying callable on ``.fn`` on current FastMCP
# 2.x but we fall back to the wrapper itself for resilience.
underlying = getattr(fn, "fn", None) or fn
if not asyncio.iscoroutinefunction(underlying):
not_async.append(tool_name)
assert not missing, f"heavy tool(s) not registered at all: {missing}"
assert not not_async, (
f"these tools must be async but were registered as sync, "
f"which will hang the stdio event loop on Windows: {not_async}"
)
def test_heavy_tool_source_uses_to_thread(self):
"""Defense in depth: the source of every heavy tool wrapper must
literally call asyncio.to_thread so we don't accidentally turn
a tool async without offloading the blocking work."""
for tool_name in self.HEAVY_TOOLS:
fn = getattr(crg_main, tool_name, None)
assert fn is not None, f"{tool_name} not found on module"
# The @mcp.tool() decorator wraps the original function; walk
# through the wrapper to find the underlying source.
underlying = getattr(fn, "fn", None) or fn
source = inspect.getsource(underlying)
assert "asyncio.to_thread" in source, (
f"{tool_name} must call asyncio.to_thread to offload its "
f"blocking work; otherwise Windows MCP clients will hang. "
f"See #46, #136."
)
@pytest.mark.asyncio
async def test_detect_changes_timeout_uses_error_response_shape(
self, monkeypatch
):
async def fake_wait_for(coro, timeout):
coro.close()
raise asyncio.TimeoutError
monkeypatch.setenv("CRG_TOOL_TIMEOUT", "1")
monkeypatch.setattr(crg_main.asyncio, "wait_for", fake_wait_for)
tool = getattr(crg_main.detect_changes_tool, "fn", None)
underlying = tool or crg_main.detect_changes_tool
result = await underlying()
assert result["status"] == "error"
assert "timed out after 1s" in result["error"]
assert result["summary"] == result["error"]
def test_regression_guard_does_not_depend_on_fastmcp_internals(self):
"""Regression guard for #239 bug 3: ensure the async guards above
resolve heavy tools by module attribute lookup, NOT through a
FastMCP internal API that may drift between releases.
The original ``test_heavy_tools_are_coroutines`` called an API on
the mcp instance that does not exist in ``fastmcp>=2.14.0``. It
died with ``AttributeError`` at runtime on every platform,
silently disabling the async-regression guard that was supposed
to protect #46/#136 from regressing. This test locks in the
module-lookup approach so the guards keep working regardless of
internal FastMCP surface changes.
"""
import ast as _ast
# Every heavy tool must be reachable by plain getattr on the
# module — that's the only API surface the guards are allowed to
# use. No mcp internals.
for tool_name in self.HEAVY_TOOLS:
fn = getattr(crg_main, tool_name, None)
assert fn is not None, (
f"{tool_name} must be reachable via "
f"getattr(crg_main, tool_name) so the async guards "
f"do not depend on any FastMCP internal API"
)
# And the guards themselves must not reference renamed/removed
# APIs on the mcp instance. We check the parsed AST of the
# function bodies (not the docstrings) so an explanatory comment
# mentioning an old API name doesn't trip this guard.
forbidden_mcp_attrs = {
"get_tools", "_tools", "tool_manager", "_tool_manager",
}
for guard_fn in (
self.test_heavy_tools_are_coroutines,
self.test_heavy_tool_source_uses_to_thread,
):
source = inspect.getsource(guard_fn).lstrip()
tree = _ast.parse(source)
for node in _ast.walk(tree):
# We want chained attributes like ``crg_main.mcp.get_tools``.
# That's an Attribute whose value is also an Attribute whose
# attr == "mcp".
if (
isinstance(node, _ast.Attribute)
and node.attr in forbidden_mcp_attrs
and isinstance(node.value, _ast.Attribute)
and node.value.attr == "mcp"
):
raise AssertionError(
f"{guard_fn.__name__} references mcp.{node.attr}"
f"this attribute drifts across FastMCP releases "
f"and will silently break the guard. Use "
f"getattr(crg_main, tool_name) instead."
)
class TestApplyToolFilter:
"""Tests for _apply_tool_filter (``serve --tools`` / ``CRG_TOOLS``).
The filter removes MCP tools not present in the allow-list.
This dramatically reduces per-turn token overhead in LLM-backed
MCP clients by pruning unused tool descriptions.
"""
@pytest.fixture(autouse=True)
def _restore_tools(self):
"""Snapshot registered tools before test, restore after.
``_apply_tool_filter`` calls ``mcp.remove_tool()`` which is
permanent. We snapshot the list of Tool objects via the public
``list_tools()`` async API (FastMCP >=3) and re-register them
after the test body runs.
"""
import asyncio
original = asyncio.run(crg_main.mcp.list_tools())
yield
current_names = {
t.name for t in asyncio.run(crg_main.mcp.list_tools())
}
for tool in original:
if tool.name not in current_names:
crg_main.mcp.add_tool(tool)
@pytest.fixture(autouse=True)
def _clean_env(self, monkeypatch):
"""Ensure CRG_TOOLS is not set from the outer environment."""
monkeypatch.delenv("CRG_TOOLS", raising=False)
@staticmethod
async def _tool_names() -> set[str]:
return {t.name for t in await crg_main.mcp.list_tools()}
@pytest.mark.asyncio
async def test_no_filter_keeps_all_tools(self):
"""When neither --tools nor CRG_TOOLS is set, all tools remain."""
before = await self._tool_names()
crg_main._apply_tool_filter(None)
after = await self._tool_names()
assert before == after
@pytest.mark.asyncio
async def test_filter_via_argument(self):
"""The ``tools`` argument keeps only the listed tools."""
keep = "query_graph_tool,semantic_search_nodes_tool"
crg_main._apply_tool_filter(keep)
remaining = await self._tool_names()
assert remaining == {"query_graph_tool", "semantic_search_nodes_tool"}
@pytest.mark.asyncio
async def test_filter_via_env_var(self, monkeypatch):
"""The ``CRG_TOOLS`` env var works as fallback."""
monkeypatch.setenv("CRG_TOOLS", "query_graph_tool")
crg_main._apply_tool_filter(None)
remaining = await self._tool_names()
assert remaining == {"query_graph_tool"}
@pytest.mark.asyncio
async def test_argument_takes_precedence_over_env(self, monkeypatch):
"""CLI --tools wins over CRG_TOOLS env var."""
monkeypatch.setenv("CRG_TOOLS", "list_repos_tool")
crg_main._apply_tool_filter("query_graph_tool")
remaining = await self._tool_names()
assert remaining == {"query_graph_tool"}
@pytest.mark.asyncio
async def test_empty_string_is_noop(self):
"""An empty string should not remove all tools."""
before = await self._tool_names()
crg_main._apply_tool_filter("")
after = await self._tool_names()
assert before == after
@pytest.mark.asyncio
async def test_whitespace_handling(self):
"""Spaces around tool names are stripped."""
crg_main._apply_tool_filter(" query_graph_tool , semantic_search_nodes_tool ")
remaining = await self._tool_names()
assert remaining == {"query_graph_tool", "semantic_search_nodes_tool"}
+151
View File
@@ -0,0 +1,151 @@
"""Tests for the schema migration framework."""
import sqlite3
import tempfile
from pathlib import Path
from code_review_graph.graph import GraphStore
from code_review_graph.migrations import (
LATEST_VERSION,
MIGRATIONS,
get_schema_version,
run_migrations,
)
class TestMigrations:
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def test_fresh_db_gets_latest_version(self):
"""A newly created DB should be at the latest schema version."""
version = get_schema_version(self.store._conn)
assert version == LATEST_VERSION
def test_v1_db_migrates_to_latest(self):
"""A v1 database should migrate to latest when GraphStore is opened."""
# Close the store that was already migrated
self.store.close()
# Manually create a v1 database (base schema only, version=1)
conn = sqlite3.connect(str(self.tmp.name))
conn.execute(
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', '1')"
)
conn.commit()
# Drop migration artifacts to simulate v1
conn.execute("DROP TABLE IF EXISTS flows")
conn.execute("DROP TABLE IF EXISTS flow_memberships")
conn.execute("DROP TABLE IF EXISTS communities")
conn.execute("DROP TABLE IF EXISTS nodes_fts")
conn.execute("DROP TABLE IF EXISTS community_summaries")
conn.execute("DROP TABLE IF EXISTS flow_snapshots")
conn.execute("DROP TABLE IF EXISTS risk_index")
conn.commit()
conn.close()
# Re-open with GraphStore — should trigger migrations
self.store = GraphStore(self.tmp.name)
assert get_schema_version(self.store._conn) == LATEST_VERSION
def test_migration_is_idempotent(self):
"""Opening GraphStore twice should leave schema at latest version."""
self.store.close()
self.store = GraphStore(self.tmp.name)
assert get_schema_version(self.store._conn) == LATEST_VERSION
self.store.close()
self.store = GraphStore(self.tmp.name)
assert get_schema_version(self.store._conn) == LATEST_VERSION
def test_signature_column_exists_after_migration(self):
"""The nodes table should have a 'signature' column after migration."""
cursor = self.store._conn.execute("PRAGMA table_info(nodes)")
columns = [row[1] if isinstance(row, tuple) else row["name"] for row in cursor]
assert "signature" in columns
def test_flows_table_exists_after_migration(self):
"""The flows and flow_memberships tables should exist after migration."""
tables = _get_table_names(self.store._conn)
assert "flows" in tables
assert "flow_memberships" in tables
def test_communities_table_exists_after_migration(self):
"""The communities table should exist and nodes should have community_id."""
tables = _get_table_names(self.store._conn)
assert "communities" in tables
cursor = self.store._conn.execute("PRAGMA table_info(nodes)")
columns = [row[1] if isinstance(row, tuple) else row["name"] for row in cursor]
assert "community_id" in columns
def test_fts5_table_exists_after_migration(self):
"""The nodes_fts FTS5 virtual table should exist after migration."""
tables = _get_table_names(self.store._conn)
assert "nodes_fts" in tables
def test_get_schema_version_no_metadata_table(self):
"""get_schema_version returns 0 when metadata table doesn't exist."""
conn = sqlite3.connect(":memory:")
assert get_schema_version(conn) == 0
conn.close()
def test_get_schema_version_no_key(self):
"""get_schema_version returns 1 when metadata exists but key is missing."""
conn = sqlite3.connect(":memory:")
conn.execute(
"CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)
conn.commit()
assert get_schema_version(conn) == 1
conn.close()
def test_migrations_dict_covers_all_versions(self):
"""MIGRATIONS should have entries from 2 to LATEST_VERSION."""
expected = set(range(2, LATEST_VERSION + 1))
assert set(MIGRATIONS.keys()) == expected
def test_run_migrations_on_already_current_db(self):
"""run_migrations should be a no-op on an already-current database."""
version_before = get_schema_version(self.store._conn)
run_migrations(self.store._conn)
version_after = get_schema_version(self.store._conn)
assert version_before == version_after == LATEST_VERSION
def test_v6_summary_tables_exist(self):
"""v6 summary tables should exist after migration."""
tables = _get_table_names(self.store._conn)
assert "community_summaries" in tables
assert "flow_snapshots" in tables
assert "risk_index" in tables
def test_v6_migration_idempotent(self):
"""Running v6 migration twice should not fail."""
from code_review_graph.migrations import _migrate_v6
_migrate_v6(self.store._conn)
_migrate_v6(self.store._conn)
tables = _get_table_names(self.store._conn)
assert "community_summaries" in tables
def test_v7_compound_edge_indexes_exist(self):
"""v7 compound edge indexes should exist after migration."""
rows = self.store._conn.execute("PRAGMA index_list(edges)").fetchall()
indexes = {row[1] if isinstance(row, tuple) else row["name"] for row in rows}
assert "idx_edges_target_kind" in indexes
assert "idx_edges_source_kind" in indexes
def _get_table_names(conn: sqlite3.Connection) -> set[str]:
"""Helper: return all table/view names in the database."""
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type IN ('table', 'view')"
).fetchall()
return {row[0] if isinstance(row, (tuple, list)) else row["name"] for row in rows}
File diff suppressed because it is too large Load Diff
+445
View File
@@ -0,0 +1,445 @@
"""Tests for Jupyter notebook (.ipynb) parsing."""
import json
from pathlib import Path
import pytest
from code_review_graph.parser import _SQL_TABLE_RE, CodeParser
FIXTURES = Path(__file__).parent / "fixtures"
class TestNotebookParsing:
def setup_method(self):
self.parser = CodeParser()
self.nodes, self.edges = self.parser.parse_file(
FIXTURES / "sample_notebook.ipynb",
)
def test_detects_notebook(self):
assert self.parser.detect_language(Path("analysis.ipynb")) == "notebook"
def test_file_node_uses_python_language(self):
file_node = [n for n in self.nodes if n.kind == "File"][0]
assert file_node.language == "python"
def test_parses_python_functions(self):
funcs = [n for n in self.nodes if n.kind == "Function"]
names = {f.name for f in funcs}
assert "add" in names
assert "multiply" in names
def test_parses_python_classes(self):
classes = [n for n in self.nodes if n.kind == "Class"]
names = {c.name for c in classes}
assert "DataProcessor" in names
def test_parses_class_methods(self):
methods = [
n for n in self.nodes
if n.kind == "Function" and n.parent_name == "DataProcessor"
]
names = {m.name for m in methods}
assert "__init__" in names
assert "process" in names
def test_cell_index_tracking(self):
funcs = {n.name: n for n in self.nodes if n.kind == "Function"}
# add and multiply are in cell index 2 (3rd code cell, 0-based)
assert funcs["add"].extra.get("cell_index") == 2
assert funcs["multiply"].extra.get("cell_index") == 2
# DataProcessor.__init__ is in cell index 3
assert funcs["__init__"].extra.get("cell_index") == 3
def test_cross_cell_calls(self):
calls = [e for e in self.edges if e.kind == "CALLS"]
targets = {e.target.split("::")[-1] for e in calls}
# process() calls add() and multiply() from different cells
assert "add" in targets
assert "multiply" in targets
def test_imports_from_cells(self):
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
targets = {e.target for e in imports}
assert "os" in targets
assert "pathlib" in targets
assert "math" in targets
def test_skips_magic_commands(self):
# %pip and !ls lines should be filtered out — no parse errors
funcs = [n for n in self.nodes if n.kind == "Function"]
assert len(funcs) >= 4 # add, multiply, __init__, process
def test_empty_notebook(self):
nb = {
"cells": [],
"metadata": {"kernelspec": {"language": "python"}},
"nbformat": 4,
}
source = json.dumps(nb).encode("utf-8")
nodes, edges = self.parser.parse_bytes(
Path("empty.ipynb"), source,
)
assert len(nodes) == 1
assert nodes[0].kind == "File"
assert edges == []
def test_non_python_kernel(self):
nb = {
"cells": [
{"cell_type": "code", "source": ["println(\"hello\")"], "outputs": []},
],
"metadata": {"kernelspec": {"language": "scala"}},
"nbformat": 4,
}
source = json.dumps(nb).encode("utf-8")
nodes, edges = self.parser.parse_bytes(
Path("scala_notebook.ipynb"), source,
)
assert nodes == []
assert edges == []
def test_malformed_json(self):
source = b"not valid json {{"
nodes, edges = self.parser.parse_bytes(
Path("bad.ipynb"), source,
)
assert nodes == []
assert edges == []
class TestSqlTableExtraction:
def test_from_clause(self):
matches = _SQL_TABLE_RE.findall("SELECT * FROM my_table")
assert "my_table" in matches
def test_qualified_table(self):
matches = _SQL_TABLE_RE.findall("SELECT * FROM catalog.schema.table")
assert "catalog.schema.table" in matches
def test_join(self):
matches = _SQL_TABLE_RE.findall(
"SELECT * FROM a JOIN b ON a.id = b.id"
)
assert "a" in matches
assert "b" in matches
def test_insert_into(self):
matches = _SQL_TABLE_RE.findall("INSERT INTO target_table VALUES (1)")
assert "target_table" in matches
def test_create_table(self):
matches = _SQL_TABLE_RE.findall("CREATE TABLE my_db.new_table (id INT)")
assert "my_db.new_table" in matches
def test_create_or_replace_view(self):
matches = _SQL_TABLE_RE.findall(
"CREATE OR REPLACE VIEW my_view AS SELECT 1"
)
assert "my_view" in matches
def test_insert_overwrite(self):
matches = _SQL_TABLE_RE.findall(
"INSERT OVERWRITE catalog.schema.tbl SELECT * FROM src"
)
assert "catalog.schema.tbl" in matches
assert "src" in matches
def test_backtick_quoted(self):
matches = _SQL_TABLE_RE.findall("SELECT * FROM `my-catalog`.`schema`.`table`")
assert any("my-catalog" in m for m in matches)
def test_no_table_refs(self):
matches = _SQL_TABLE_RE.findall("SELECT 1 + 1")
assert matches == []
def test_case_insensitive(self):
matches = _SQL_TABLE_RE.findall("select * from My_Table")
assert "My_Table" in matches
class TestDatabricksNotebookParsing:
def setup_method(self):
self.parser = CodeParser()
self.nodes, self.edges = self.parser.parse_file(
FIXTURES / "sample_databricks_notebook.ipynb",
)
def test_parses_python_functions_from_magic(self):
funcs = [n for n in self.nodes if n.kind == "Function"]
names = {f.name for f in funcs}
assert "transform_data" in names
assert "process_results" in names
def test_extracts_sql_table_references(self):
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
targets = {e.target for e in imports}
assert "catalog.schema.raw_data" in targets
assert "catalog.schema.lookup" in targets
assert "catalog.schema.output" in targets
def test_skips_scala_cells(self):
names = {n.name for n in self.nodes if n.kind == "Function"}
assert "x" not in names
def test_skips_md_cells(self):
func_count = len([n for n in self.nodes if n.kind == "Function"])
assert func_count == 3 # transform_data + process_results + clean_data (R cell)
def test_default_language_for_unmagicked_cell(self):
"""Cell 6 has no magic prefix — should use kernel default (python)."""
funcs = {n.name: n for n in self.nodes if n.kind == "Function"}
assert "process_results" in funcs
def test_cell_index_tracking(self):
funcs = {n.name: n for n in self.nodes if n.kind == "Function"}
assert funcs["transform_data"].extra.get("cell_index") == 1
assert funcs["process_results"].extra.get("cell_index") == 6
def test_cross_cell_python_calls(self):
calls = [e for e in self.edges if e.kind == "CALLS"]
targets = {e.target.split("::")[-1] for e in calls}
assert "transform_data" in targets
class TestDatabricksPyNotebook:
def setup_method(self):
self.parser = CodeParser()
self.nodes, self.edges = self.parser.parse_file(
FIXTURES / "sample_databricks_export.py",
)
def test_detects_databricks_header(self):
"""Should parse as notebook, not regular Python."""
file_node = [n for n in self.nodes if n.kind == "File"][0]
assert file_node.extra.get("notebook_format") == "databricks_py"
def test_parses_python_functions(self):
funcs = [n for n in self.nodes if n.kind == "Function"]
names = {f.name for f in funcs}
assert "load_config" in names
assert "process_events" in names
def test_extracts_sql_tables(self):
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
targets = {e.target for e in imports}
assert "bronze.events" in targets
assert "silver.users" in targets
assert "gold.summary" in targets
assert "silver.processed" in targets
def test_skips_magic_md_cells(self):
funcs = [n for n in self.nodes if n.kind == "Function"]
names = {f.name for f in funcs}
assert len(names) == 3 # load_config + process_events + summarize_data (R cell)
def test_cell_index_tracking(self):
funcs = {n.name: n for n in self.nodes if n.kind == "Function"}
assert funcs["load_config"].extra.get("cell_index") == 0
assert funcs["process_events"].extra.get("cell_index") == 4
def test_python_imports(self):
imports = [
e for e in self.edges
if e.kind == "IMPORTS_FROM" and e.target in ("os", "pathlib")
]
targets = {e.target for e in imports}
assert "os" in targets
assert "pathlib" in targets
def test_cross_cell_calls(self):
calls = [e for e in self.edges if e.kind == "CALLS"]
targets = {e.target.split("::")[-1] for e in calls}
assert "load_config" in targets
def test_regular_py_not_affected(self):
"""A regular .py file (no header) should parse normally."""
source = b"def hello():\n return 'hi'\n"
nodes, edges = self.parser.parse_bytes(Path("regular.py"), source)
funcs = [n for n in nodes if n.kind == "Function"]
assert len(funcs) == 1
assert funcs[0].name == "hello"
file_node = [n for n in nodes if n.kind == "File"][0]
assert "notebook_format" not in file_node.extra
def test_databricks_header_crlf_line_endings(self):
"""Regression guard for #239 bug 2: the Databricks auto-detection
must handle ``\\r\\n`` (CRLF) line endings as well as ``\\n`` (LF).
On Windows, ``git config core.autocrlf=true`` (the default) rewrites
text files to CRLF on checkout. Before the fix, the detection
``source.startswith(b"# Databricks notebook source\\n")`` matched
only LF, so Windows checkouts silently parsed Databricks exports
as plain Python — missing SQL-cell table extraction, cell-index
metadata, and the ``notebook_format`` tag.
"""
# Exact byte sequence a Windows checkout produces.
crlf_source = (
b"# Databricks notebook source\r\n"
b"# COMMAND ----------\r\n"
b"\r\n"
b"def crlf_fn():\r\n"
b" return 1\r\n"
)
nodes, edges = self.parser.parse_bytes(Path("nb.py"), crlf_source)
file_nodes = [n for n in nodes if n.kind == "File"]
assert len(file_nodes) == 1
assert file_nodes[0].extra.get("notebook_format") == "databricks_py", (
"Databricks header with CRLF line endings was not detected; "
"the auto-detect check is still hard-coded to \\n only"
)
# The body function must still be extracted through the notebook path.
funcs = [n for n in nodes if n.kind == "Function"]
assert any(f.name == "crlf_fn" for f in funcs)
def test_databricks_header_lf_line_endings_still_work(self):
"""Regression guard for #239 bug 2: ensure the CRLF fix does not
break the existing LF path (pre-existing behavior)."""
lf_source = (
b"# Databricks notebook source\n"
b"# COMMAND ----------\n"
b"\n"
b"def lf_fn():\n"
b" return 1\n"
)
nodes, edges = self.parser.parse_bytes(Path("nb.py"), lf_source)
file_nodes = [n for n in nodes if n.kind == "File"]
assert len(file_nodes) == 1
assert file_nodes[0].extra.get("notebook_format") == "databricks_py"
def test_databricks_header_prefix_false_positive_rejected(self):
"""Regression guard for #239 bug 2: a file whose first line only
*starts with* the Databricks phrase but has extra characters must
NOT be detected as a Databricks export. Protects against the
naive fix of using ``startswith`` without checking the line end.
"""
false_positive = (
b"# Databricks notebook source code examples\n"
b"def hello(): return 1\n"
)
nodes, edges = self.parser.parse_bytes(Path("doc.py"), false_positive)
file_nodes = [n for n in nodes if n.kind == "File"]
assert len(file_nodes) == 1
assert "notebook_format" not in file_nodes[0].extra, (
"a regular .py file whose first comment happens to start with "
"'# Databricks notebook source' must not trigger Databricks "
"parsing"
)
class TestRKernelNotebook:
def setup_method(self):
self.parser = CodeParser()
nb = {
"cells": [
{
"cell_type": "code",
"source": [
"library(dplyr)\n",
],
"outputs": [],
},
{
"cell_type": "code",
"source": [
"clean_data <- function(df) {\n",
" df %>% filter(!is.na(value))\n",
"}\n",
],
"outputs": [],
},
],
"metadata": {"kernelspec": {"language": "r"}},
"nbformat": 4,
}
source = json.dumps(nb).encode("utf-8")
self.nodes, self.edges = self.parser.parse_bytes(
Path("analysis.ipynb"), source,
)
def test_r_kernel_not_skipped(self):
"""R-kernel notebooks should now be parsed, not skipped."""
assert len(self.nodes) >= 1
file_node = [n for n in self.nodes if n.kind == "File"][0]
assert file_node.language == "r"
@pytest.mark.xfail(reason="Requires R parser mappings from PR #43")
def test_r_kernel_detects_functions(self):
funcs = [n for n in self.nodes if n.kind == "Function"]
names = {f.name for f in funcs}
assert "clean_data" in names
@pytest.mark.xfail(reason="Requires R parser mappings from PR #43")
def test_r_kernel_detects_imports(self):
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
targets = {e.target for e in imports}
assert "dplyr" in targets
class TestNotebookEdgeCases:
def setup_method(self):
self.parser = CodeParser()
def test_databricks_header_not_on_line_1(self):
"""Header on line 2 should be treated as regular Python."""
source = b"# comment\n# Databricks notebook source\ndef foo(): pass\n"
nodes, edges = self.parser.parse_bytes(Path("not_db.py"), source)
file_node = [n for n in nodes if n.kind == "File"][0]
assert "notebook_format" not in file_node.extra
def test_databricks_py_no_command_delimiters(self):
"""Header present but no COMMAND delimiters — single Python cell."""
source = b"# Databricks notebook source\ndef foo():\n return 1\n"
nodes, edges = self.parser.parse_bytes(Path("single_cell.py"), source)
funcs = [n for n in nodes if n.kind == "Function"]
assert len(funcs) == 1
assert funcs[0].name == "foo"
def test_empty_databricks_cells(self):
"""Cells with only magic/shell lines should be skipped."""
nb = {
"cells": [
{"cell_type": "code", "source": ["%pip install foo\n"], "outputs": []},
{"cell_type": "code", "source": ["!ls\n"], "outputs": []},
{"cell_type": "code", "source": ["def real(): pass\n"], "outputs": []},
],
"metadata": {"kernelspec": {"language": "python"}},
"nbformat": 4,
}
source = json.dumps(nb).encode("utf-8")
nodes, edges = self.parser.parse_bytes(Path("sparse.ipynb"), source)
funcs = [n for n in nodes if n.kind == "Function"]
assert len(funcs) == 1
assert funcs[0].name == "real"
def test_sql_cell_no_tables(self):
"""SQL cell with no table refs should produce no edges."""
nb = {
"cells": [
{"cell_type": "code", "source": ["%sql\n", "SELECT 1 + 1\n"], "outputs": []},
],
"metadata": {"kernelspec": {"language": "python"}},
"nbformat": 4,
}
source = json.dumps(nb).encode("utf-8")
nodes, edges = self.parser.parse_bytes(Path("no_tables.ipynb"), source)
imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
assert imports == []
def test_conflicting_kernel_metadata(self):
"""kernelspec.language takes precedence over language_info.name."""
nb = {
"cells": [
{"cell_type": "code", "source": ["def foo(): pass\n"], "outputs": []},
],
"metadata": {
"kernelspec": {"language": "python"},
"language_info": {"name": "r"},
},
"nbformat": 4,
}
source = json.dumps(nb).encode("utf-8")
nodes, edges = self.parser.parse_bytes(Path("conflict.ipynb"), source)
file_node = [n for n in nodes if n.kind == "File"][0]
assert file_node.language == "python"
+1460
View File
File diff suppressed because it is too large Load Diff
+327
View File
@@ -0,0 +1,327 @@
"""Tests for the shared post-processing pipeline."""
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import full_build
from code_review_graph.parser import EdgeInfo, NodeInfo
from code_review_graph.postprocessing import run_post_processing
def _get_signature(store, qualified_name):
row = store._conn.execute(
"SELECT signature FROM nodes WHERE qualified_name = ?",
(qualified_name,),
).fetchone()
return row["signature"] if row else None
class TestRunPostProcessing:
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self._seed_data()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _seed_data(self):
self.store.upsert_node(
NodeInfo(
kind="File",
name="/repo/app.py",
file_path="/repo/app.py",
line_start=1,
line_end=50,
language="python",
)
)
self.store.upsert_node(
NodeInfo(
kind="Class",
name="Service",
file_path="/repo/app.py",
line_start=5,
line_end=40,
language="python",
)
)
self.store.upsert_node(
NodeInfo(
kind="Function",
name="handle",
file_path="/repo/app.py",
line_start=10,
line_end=20,
language="python",
parent_name="Service",
params="request",
return_type="Response",
)
)
self.store.upsert_node(
NodeInfo(
kind="Function",
name="process",
file_path="/repo/app.py",
line_start=25,
line_end=35,
language="python",
)
)
self.store.upsert_node(
NodeInfo(
kind="Test",
name="test_handle",
file_path="/repo/test_app.py",
line_start=1,
line_end=10,
language="python",
is_test=True,
)
)
self.store.upsert_edge(
EdgeInfo(
kind="CONTAINS",
source="/repo/app.py",
target="/repo/app.py::Service",
file_path="/repo/app.py",
)
)
self.store.upsert_edge(
EdgeInfo(
kind="CONTAINS",
source="/repo/app.py::Service",
target="/repo/app.py::Service.handle",
file_path="/repo/app.py",
)
)
self.store.upsert_edge(
EdgeInfo(
kind="CALLS",
source="/repo/app.py::Service.handle",
target="/repo/app.py::process",
file_path="/repo/app.py",
line=15,
)
)
self.store.commit()
def test_computes_signatures(self):
unsigned = self.store.get_nodes_without_signature()
assert len(unsigned) > 0
result = run_post_processing(self.store)
assert result["signatures_computed"] > 0
remaining = self.store.get_nodes_without_signature()
assert len(remaining) == 0
def test_function_signature_format(self):
run_post_processing(self.store)
sig = _get_signature(self.store, "/repo/app.py::Service.handle")
assert sig == "def handle(request) -> Response"
def test_class_signature_format(self):
run_post_processing(self.store)
sig = _get_signature(self.store, "/repo/app.py::Service")
assert sig == "class Service"
def test_test_signature_format(self):
run_post_processing(self.store)
sig = _get_signature(self.store, "/repo/test_app.py::test_handle")
assert sig is not None
assert sig.startswith("def test_handle(")
def test_rebuilds_fts_index(self):
result = run_post_processing(self.store)
assert "fts_indexed" in result
assert result["fts_indexed"] > 0
def test_fts_search_works_after_post_processing(self):
run_post_processing(self.store)
from code_review_graph.search import hybrid_search
hits = hybrid_search(self.store, "handle")
names = {h["name"] for h in hits}
assert "handle" in names
def test_detects_flows(self):
result = run_post_processing(self.store)
assert "flows_detected" in result
assert result["flows_detected"] >= 0
def test_detects_communities(self):
result = run_post_processing(self.store)
assert "communities_detected" in result
assert result["communities_detected"] >= 0
def test_no_warnings_on_healthy_store(self):
result = run_post_processing(self.store)
assert "warnings" not in result
def test_empty_store_no_crash(self):
empty_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
empty_store = GraphStore(empty_tmp.name)
try:
result = run_post_processing(empty_store)
assert result["signatures_computed"] == 0
assert result["fts_indexed"] == 0
finally:
empty_store.close()
Path(empty_tmp.name).unlink(missing_ok=True)
def test_idempotent(self):
first = run_post_processing(self.store)
second = run_post_processing(self.store)
assert second["fts_indexed"] == first["fts_indexed"]
assert second["signatures_computed"] == 0
def test_signature_truncated_at_512(self):
self.store.upsert_node(
NodeInfo(
kind="Function",
name="f",
file_path="/repo/big.py",
line_start=1,
line_end=2,
language="python",
params="a" * 600,
)
)
self.store.commit()
run_post_processing(self.store)
sig = _get_signature(self.store, "/repo/big.py::f")
assert sig is not None
assert len(sig) <= 512
class TestPostProcessingStepIsolation:
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self.store.upsert_node(
NodeInfo(
kind="Function",
name="fn",
file_path="/repo/a.py",
line_start=1,
line_end=5,
language="python",
)
)
self.store.commit()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def test_fts_failure_does_not_block_flows(self):
with patch(
"code_review_graph.search.rebuild_fts_index",
side_effect=ImportError("fts boom"),
):
result = run_post_processing(self.store)
assert "flows_detected" in result
assert "communities_detected" in result
assert "warnings" in result
assert any("FTS" in w for w in result["warnings"])
def test_flow_failure_does_not_block_communities(self):
with patch(
"code_review_graph.flows.trace_flows",
side_effect=ImportError("flow boom"),
):
result = run_post_processing(self.store)
assert "communities_detected" in result
assert "warnings" in result
assert any("Flow" in w for w in result["warnings"])
def test_community_failure_still_has_signatures(self):
with patch(
"code_review_graph.communities.detect_communities",
side_effect=ImportError("comm boom"),
):
result = run_post_processing(self.store)
assert result["signatures_computed"] > 0
assert "warnings" in result
assert any("Community" in w for w in result["warnings"])
class TestToolBuildUsesSharedPipeline:
def test_build_tool_runs_post_processing(self, tmp_path):
py_file = tmp_path / "sample.py"
py_file.write_text("def hello():\n pass\n")
(tmp_path / ".git").mkdir()
(tmp_path / ".code-review-graph").mkdir()
db_path = tmp_path / ".code-review-graph" / "graph.db"
store = GraphStore(db_path)
try:
mock_target = "code_review_graph.incremental.get_all_tracked_files"
with patch(mock_target, return_value=["sample.py"]):
full_build(tmp_path, store)
unsigned_before_pp = store.get_nodes_without_signature()
run_post_processing(store)
unsigned_after_pp = store.get_nodes_without_signature()
assert len(unsigned_before_pp) > 0
assert len(unsigned_after_pp) == 0
finally:
store.close()
class TestWatchCallbackIntegration:
def test_watch_accepts_callback_parameter(self):
import inspect
from code_review_graph.incremental import watch
sig = inspect.signature(watch)
assert "on_files_updated" in sig.parameters
def test_watch_callback_not_called_without_updates(self, tmp_path):
import threading
from code_review_graph.incremental import watch
(tmp_path / ".git").mkdir()
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
callback = MagicMock()
try:
def run_watch():
try:
watch(tmp_path, store, on_files_updated=callback)
except KeyboardInterrupt:
pass
t = threading.Thread(target=run_watch, daemon=True)
t.start()
import time
time.sleep(0.5)
callback.assert_not_called()
finally:
store.close()
+192
View File
@@ -0,0 +1,192 @@
"""Tests for MCP prompt templates."""
from fastmcp.prompts.prompt import Message
from code_review_graph.prompts import (
architecture_map_prompt,
debug_issue_prompt,
onboard_developer_prompt,
pre_merge_check_prompt,
review_changes_prompt,
)
def _text(msg: Message) -> str:
"""Extract the text content from a fastmcp Message."""
return msg.content.text
class TestReviewChangesPrompt:
def test_returns_list_with_messages(self):
result = review_changes_prompt()
assert isinstance(result, list)
assert len(result) >= 1
def test_message_has_role_and_content(self):
result = review_changes_prompt()
for msg in result:
assert isinstance(msg, Message)
assert msg.role == "user"
assert _text(msg)
def test_default_base(self):
result = review_changes_prompt()
assert "HEAD~1" in _text(result[0])
def test_custom_base(self):
result = review_changes_prompt(base="main")
assert "main" in _text(result[0])
def test_mentions_detect_changes(self):
result = review_changes_prompt()
assert "detect_changes" in _text(result[0])
def test_mentions_affected_flows(self):
result = review_changes_prompt()
assert "affected_flows" in _text(result[0])
def test_mentions_test_gaps(self):
result = review_changes_prompt()
assert "test" in _text(result[0]).lower()
class TestArchitectureMapPrompt:
def test_returns_list_with_messages(self):
result = architecture_map_prompt()
assert isinstance(result, list)
assert len(result) >= 1
def test_message_has_role_and_content(self):
result = architecture_map_prompt()
for msg in result:
assert isinstance(msg, Message)
assert msg.role == "user"
assert _text(msg)
def test_mentions_communities(self):
result = architecture_map_prompt()
assert "communities" in _text(result[0]).lower()
def test_mentions_mermaid(self):
result = architecture_map_prompt()
assert "Mermaid" in _text(result[0])
class TestDebugIssuePrompt:
def test_returns_list_with_messages(self):
result = debug_issue_prompt()
assert isinstance(result, list)
assert len(result) >= 1
def test_message_has_role_and_content(self):
result = debug_issue_prompt()
for msg in result:
assert isinstance(msg, Message)
assert msg.role == "user"
assert _text(msg)
def test_includes_description(self):
result = debug_issue_prompt(description="login fails with 500 error")
assert "login fails with 500 error" in _text(result[0])
def test_empty_description(self):
result = debug_issue_prompt()
content = _text(result[0])
assert "debug" in content.lower()
def test_mentions_search(self):
result = debug_issue_prompt(description="test issue")
assert "semantic_search_nodes" in _text(result[0])
def test_mentions_get_minimal_context(self):
result = debug_issue_prompt()
assert "get_minimal_context" in _text(result[0])
class TestOnboardDeveloperPrompt:
def test_returns_list_with_messages(self):
result = onboard_developer_prompt()
assert isinstance(result, list)
assert len(result) >= 1
def test_message_has_role_and_content(self):
result = onboard_developer_prompt()
for msg in result:
assert isinstance(msg, Message)
assert msg.role == "user"
assert _text(msg)
def test_mentions_stats(self):
result = onboard_developer_prompt()
assert "list_graph_stats" in _text(result[0])
def test_mentions_architecture(self):
result = onboard_developer_prompt()
assert "architecture" in _text(result[0]).lower()
def test_mentions_critical_flows(self):
result = onboard_developer_prompt()
assert "critical" in _text(result[0]).lower()
class TestPreMergeCheckPrompt:
def test_returns_list_with_messages(self):
result = pre_merge_check_prompt()
assert isinstance(result, list)
assert len(result) >= 1
def test_message_has_role_and_content(self):
result = pre_merge_check_prompt()
for msg in result:
assert isinstance(msg, Message)
assert msg.role == "user"
assert _text(msg)
def test_default_base(self):
result = pre_merge_check_prompt()
# The pre-merge prompt is now generic (doesn't embed the base ref)
assert "pre-merge" in _text(result[0]).lower()
def test_custom_base(self):
# pre_merge_check_prompt still accepts base but the workflow
# is now generic — just verify it returns valid prompt
result = pre_merge_check_prompt(base="develop")
assert isinstance(result, list)
assert len(result) >= 1
def test_mentions_risk_scoring(self):
result = pre_merge_check_prompt()
assert "risk" in _text(result[0]).lower()
def test_mentions_test_gaps(self):
result = pre_merge_check_prompt()
assert "tests_for" in _text(result[0])
def test_mentions_dead_code(self):
result = pre_merge_check_prompt()
assert "dead_code" in _text(result[0])
class TestTokenEfficiencyPreamble:
"""All prompts should include the token efficiency preamble."""
def test_review_has_preamble(self):
result = review_changes_prompt()
assert "get_minimal_context" in _text(result[0])
assert "detail_level" in _text(result[0])
def test_architecture_has_preamble(self):
result = architecture_map_prompt()
assert "get_minimal_context" in _text(result[0])
def test_debug_has_preamble(self):
result = debug_issue_prompt()
assert "get_minimal_context" in _text(result[0])
def test_onboard_has_preamble(self):
result = onboard_developer_prompt()
assert "get_minimal_context" in _text(result[0])
def test_pre_merge_has_preamble(self):
result = pre_merge_check_prompt()
assert "get_minimal_context" in _text(result[0])
+901
View File
@@ -0,0 +1,901 @@
"""Tests for graph-powered refactoring operations."""
import tempfile
import threading
import time
from pathlib import Path
from code_review_graph.graph import GraphStore
from code_review_graph.parser import CodeParser, EdgeInfo, NodeInfo
from code_review_graph.refactor import (
REFACTOR_EXPIRY_SECONDS,
_pending_refactors,
_refactor_lock,
apply_refactor,
find_dead_code,
rename_preview,
suggest_refactorings,
)
class TestRenamePreview:
"""Tests for rename_preview."""
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self._seed()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
# Clean up pending refactors.
with _refactor_lock:
_pending_refactors.clear()
def _seed(self):
"""Seed the store with test data for rename tests."""
# File nodes
self.store.upsert_node(NodeInfo(
kind="File", name="/repo/utils.py", file_path="/repo/utils.py",
line_start=1, line_end=50, language="python",
))
self.store.upsert_node(NodeInfo(
kind="File", name="/repo/main.py", file_path="/repo/main.py",
line_start=1, line_end=30, language="python",
))
# Function to rename
self.store.upsert_node(NodeInfo(
kind="Function", name="helper", file_path="/repo/utils.py",
line_start=10, line_end=20, language="python",
))
# Caller function
self.store.upsert_node(NodeInfo(
kind="Function", name="run", file_path="/repo/main.py",
line_start=5, line_end=15, language="python",
))
# CALLS edge: run -> helper
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="/repo/main.py::run",
target="/repo/utils.py::helper", file_path="/repo/main.py", line=10,
))
# IMPORTS_FROM edge: main.py imports helper
self.store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source="/repo/main.py",
target="/repo/utils.py::helper", file_path="/repo/main.py", line=1,
))
self.store.commit()
def test_rename_preview_returns_edits_with_refactor_id(self):
"""rename_preview returns a dict with refactor_id and edits."""
result = rename_preview(self.store, "helper", "new_helper")
assert result is not None
assert "refactor_id" in result
assert len(result["refactor_id"]) == 8
assert result["type"] == "rename"
assert result["old_name"] == "helper"
assert result["new_name"] == "new_helper"
assert isinstance(result["edits"], list)
assert len(result["edits"]) > 0
assert "stats" in result
assert result["stats"]["high"] > 0
def test_rename_finds_callers(self):
"""rename_preview finds definition + call sites."""
result = rename_preview(self.store, "helper", "new_helper")
assert result is not None
edits = result["edits"]
# Should have at least: 1 definition + 1 call + 1 import = 3
assert len(edits) >= 3
files = {e["file"] for e in edits}
assert "/repo/utils.py" in files # definition
assert "/repo/main.py" in files # call site + import site
def test_rename_not_found(self):
"""rename_preview returns None if symbol not found."""
result = rename_preview(self.store, "nonexistent_function", "new_name")
assert result is None
def test_rename_stores_in_pending(self):
"""rename_preview stores the preview in _pending_refactors."""
result = rename_preview(self.store, "helper", "new_helper")
assert result is not None
rid = result["refactor_id"]
with _refactor_lock:
assert rid in _pending_refactors
class TestFindDeadCode:
"""Tests for find_dead_code."""
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self._seed()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _seed(self):
"""Seed with a mix of used and unused functions."""
# File
self.store.upsert_node(NodeInfo(
kind="File", name="/repo/app.py", file_path="/repo/app.py",
line_start=1, line_end=100, language="python",
))
# A function that IS called
self.store.upsert_node(NodeInfo(
kind="Function", name="used_func", file_path="/repo/app.py",
line_start=10, line_end=20, language="python",
))
# A function that is NOT called (dead code)
self.store.upsert_node(NodeInfo(
kind="Function", name="dead_func", file_path="/repo/app.py",
line_start=30, line_end=40, language="python",
))
# An entry point function (should be excluded)
self.store.upsert_node(NodeInfo(
kind="Function", name="main", file_path="/repo/app.py",
line_start=50, line_end=60, language="python",
))
# A test function (should be excluded)
self.store.upsert_node(NodeInfo(
kind="Test", name="test_something", file_path="/repo/test_app.py",
line_start=1, line_end=10, language="python", is_test=True,
))
# Caller for used_func
self.store.upsert_node(NodeInfo(
kind="Function", name="caller", file_path="/repo/app.py",
line_start=70, line_end=80, language="python",
))
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="/repo/app.py::caller",
target="/repo/app.py::used_func", file_path="/repo/app.py", line=75,
))
self.store.commit()
def test_find_dead_code(self):
"""find_dead_code detects unreferenced functions."""
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "dead_func" in dead_names
def test_find_dead_code_response_fields(self):
"""dead_code entries include file_path, relative_path, and language."""
dead = find_dead_code(self.store, root="/repo")
entry = next(d for d in dead if d["name"] == "dead_func")
assert entry["file_path"] == "/repo/app.py"
assert entry["relative_path"] == "app.py"
assert entry["language"] == "python"
# backward compat: 'file' key still present
assert entry["file"] == "/repo/app.py"
def test_find_dead_code_relative_path_without_root(self):
"""Without root, relative_path falls back to file_path."""
dead = find_dead_code(self.store)
entry = next(d for d in dead if d["name"] == "dead_func")
assert entry["relative_path"] == "/repo/app.py"
def test_find_dead_code_excludes_called(self):
"""find_dead_code does NOT include functions with callers."""
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "used_func" not in dead_names
def test_find_dead_code_excludes_entry_points(self):
"""Entry points (like 'main') are not flagged as dead code."""
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "main" not in dead_names
def test_find_dead_code_excludes_tests(self):
"""Test nodes are not flagged as dead code."""
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "test_something" not in dead_names
def test_find_dead_code_kind_filter(self):
"""kind filter restricts results."""
dead = find_dead_code(self.store, kind="Class")
# We have no Class nodes, so should be empty
assert len(dead) == 0
def test_find_dead_code_file_pattern(self):
"""file_pattern filter works."""
dead = find_dead_code(self.store, file_pattern="nonexistent")
assert len(dead) == 0
def test_find_dead_code_excludes_dunder(self):
"""Dunder methods are not flagged as dead code."""
self.store.upsert_node(NodeInfo(
kind="Function", name="__init__", file_path="/repo/app.py",
line_start=90, line_end=95, language="python",
parent_name="MyClass",
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "__init__" not in dead_names
def test_find_dead_code_excludes_constructor(self):
"""JS/TS constructors are not flagged as dead code."""
self.store.upsert_node(NodeInfo(
kind="Function", name="constructor", file_path="/repo/component.ts",
line_start=10, line_end=15, language="typescript",
parent_name="MyComponent",
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "constructor" not in dead_names
def test_find_dead_code_excludes_angular_lifecycle(self):
"""Angular lifecycle hooks are not flagged as dead code."""
for name in ("ngOnInit", "ngOnChanges", "ngOnDestroy", "transform",
"writeValue", "canActivate"):
self.store.upsert_node(NodeInfo(
kind="Function", name=name, file_path="/repo/component.ts",
line_start=10, line_end=15, language="typescript",
parent_name="MyComponent",
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
for name in ("ngOnInit", "ngOnChanges", "ngOnDestroy", "transform",
"writeValue", "canActivate"):
assert name not in dead_names, f"{name} should not be dead"
def test_find_dead_code_excludes_decorated_entry(self):
"""Functions with framework decorators are not flagged as dead code."""
self.store.upsert_node(NodeInfo(
kind="Function", name="get_users", file_path="/repo/app.py",
line_start=90, line_end=95, language="python",
extra={"decorators": ["app.get('/users')"]},
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "get_users" not in dead_names
def test_find_dead_code_excludes_type_referenced_class(self):
"""Classes referenced in function type annotations are not dead code."""
self.store.upsert_node(NodeInfo(
kind="Class", name="UserSchema", file_path="/repo/app.py",
line_start=5, line_end=15, language="python",
))
# A function that uses UserSchema in its params
self.store.upsert_node(NodeInfo(
kind="Function", name="create_user", file_path="/repo/app.py",
line_start=20, line_end=30, language="python",
params="body: UserSchema",
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "UserSchema" not in dead_names
def test_find_dead_code_excludes_return_type_reference(self):
"""Classes referenced in return types are not dead code."""
self.store.upsert_node(NodeInfo(
kind="Class", name="UserResponse", file_path="/repo/app.py",
line_start=5, line_end=15, language="python",
))
self.store.upsert_node(NodeInfo(
kind="Function", name="get_user", file_path="/repo/app.py",
line_start=20, line_end=30, language="python",
return_type="Optional[UserResponse]",
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "UserResponse" not in dead_names
def test_find_dead_code_excludes_orm_model(self):
"""Classes inheriting from known ORM bases are not dead code."""
self.store.upsert_node(NodeInfo(
kind="Class", name="User", file_path="/repo/app.py",
line_start=5, line_end=20, language="python",
))
self.store.upsert_edge(EdgeInfo(
kind="INHERITS", source="/repo/app.py::User",
target="Base", file_path="/repo/app.py", line=5,
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "User" not in dead_names
def test_find_dead_code_excludes_pydantic_settings(self):
"""Classes inheriting from BaseSettings are not dead code."""
self.store.upsert_node(NodeInfo(
kind="Class", name="AppConfig", file_path="/repo/app.py",
line_start=5, line_end=15, language="python",
))
self.store.upsert_edge(EdgeInfo(
kind="INHERITS", source="/repo/app.py::AppConfig",
target="BaseSettings", file_path="/repo/app.py", line=5,
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "AppConfig" not in dead_names
def test_find_dead_code_excludes_agent_tool(self):
"""Functions with @agent.tool decorator are not dead code."""
self.store.upsert_node(NodeInfo(
kind="Function", name="query_data", file_path="/repo/app.py",
line_start=10, line_end=20, language="python",
extra={"decorators": ["health_agent.tool"]},
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "query_data" not in dead_names
def test_find_dead_code_excludes_alembic_upgrade(self):
"""upgrade() and downgrade() in alembic files are not dead code."""
self.store.upsert_node(NodeInfo(
kind="Function", name="upgrade", file_path="/repo/alembic/versions/001.py",
line_start=5, line_end=15, language="python",
))
self.store.upsert_node(NodeInfo(
kind="Function", name="downgrade", file_path="/repo/alembic/versions/001.py",
line_start=20, line_end=30, language="python",
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "upgrade" not in dead_names
assert "downgrade" not in dead_names
def test_find_dead_code_excludes_subclassed_class(self):
"""Classes with subclasses (INHERITS edges) are not dead code."""
self.store.upsert_node(NodeInfo(
kind="Class", name="BaseConnector", file_path="/repo/connectors.py",
line_start=5, line_end=50, language="python",
))
# A subclass inherits from BaseConnector (bare-name target)
self.store.upsert_edge(EdgeInfo(
kind="INHERITS", source="/repo/connectors.py::GarminConnector",
target="BaseConnector", file_path="/repo/connectors.py", line=60,
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "BaseConnector" not in dead_names
def test_find_dead_code_bare_name_not_tricked_by_unrelated_caller(self):
"""Bare-name CALLS from unrelated files don't save a dead function
when there are multiple definitions with the same name."""
# Two unrelated functions named "processor" in different files
self.store.upsert_node(NodeInfo(
kind="Function", name="processor", file_path="/repo/api/routes.py",
line_start=10, line_end=20, language="python",
))
self.store.upsert_node(NodeInfo(
kind="Function", name="processor", file_path="/repo/worker/tasks.py",
line_start=10, line_end=20, language="python",
))
# A bare CALLS edge from a third file that imports only routes.py
self.store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source="/repo/main.py",
target="/repo/api/routes.py", file_path="/repo/main.py", line=1,
))
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="/repo/main.py::start",
target="processor", file_path="/repo/main.py", line=10,
))
self.store.commit()
dead = find_dead_code(self.store)
dead_qnames = {d["qualified_name"] for d in dead}
# routes.py processor is saved (caller imports its file)
assert "/repo/api/routes.py::processor" not in dead_qnames
# worker/tasks.py processor is dead (no relationship with caller)
assert "/repo/worker/tasks.py::processor" in dead_qnames
def test_find_dead_code_excludes_mock_variables(self):
"""Mock/stub variables in test files are not flagged as dead code."""
for name in ("mockDynamoClient", "s3ClientMock", "MockService", "createMockRequest"):
self.store.upsert_node(NodeInfo(
kind="Function", name=name, file_path="/repo/tests/handler.spec.ts",
line_start=10, line_end=15, language="typescript",
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
for name in ("mockDynamoClient", "s3ClientMock", "MockService", "createMockRequest"):
assert name not in dead_names, f"{name} should not be dead (mock pattern)"
def test_find_dead_code_excludes_angular_decorated_class(self):
"""Angular @Component classes are not flagged as dead code."""
self.store.upsert_node(NodeInfo(
kind="Class", name="ClipboardButtonComponent",
file_path="/repo/src/app/clipboard.component.ts",
line_start=5, line_end=50, language="typescript",
extra={"decorators": ["Component({selector: 'app-clipboard'})"]},
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "ClipboardButtonComponent" not in dead_names
def test_find_dead_code_excludes_property(self):
"""Functions decorated with @property are not dead code."""
self.store.upsert_node(NodeInfo(
kind="Function", name="db", file_path="/repo/deps.py",
line_start=10, line_end=15, language="python",
extra={"decorators": ["property"]},
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "db" not in dead_names
class TestSuggestRefactorings:
"""Tests for suggest_refactorings."""
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self._seed()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _seed(self):
"""Seed with dead code to generate suggestions."""
self.store.upsert_node(NodeInfo(
kind="File", name="/repo/lib.py", file_path="/repo/lib.py",
line_start=1, line_end=50, language="python",
))
# Unreferenced function -> removal suggestion
self.store.upsert_node(NodeInfo(
kind="Function", name="orphan_func", file_path="/repo/lib.py",
line_start=10, line_end=20, language="python",
))
self.store.commit()
def test_suggest_refactorings(self):
"""suggest_refactorings returns a list of suggestions."""
suggestions = suggest_refactorings(self.store)
assert isinstance(suggestions, list)
# Should have at least the dead-code removal suggestion
assert len(suggestions) >= 1
types = {s["type"] for s in suggestions}
assert "remove" in types
def test_suggestion_structure(self):
"""Each suggestion has the required fields."""
suggestions = suggest_refactorings(self.store)
for s in suggestions:
assert "type" in s
assert "description" in s
assert "symbols" in s
assert "rationale" in s
assert s["type"] in ("move", "remove")
class TestApplyRefactor:
"""Tests for apply_refactor."""
def setup_method(self):
with _refactor_lock:
_pending_refactors.clear()
def teardown_method(self):
with _refactor_lock:
_pending_refactors.clear()
def test_apply_refactor_validates_id(self):
"""apply_refactor rejects nonexistent refactor_id."""
# Use a real temp dir as repo_root (needs .git or .code-review-graph)
tmp_dir = Path(tempfile.mkdtemp())
(tmp_dir / ".git").mkdir()
try:
result = apply_refactor("nonexistent_id", tmp_dir)
assert result["status"] == "error"
assert "not found" in result["error"].lower() or "expired" in result["error"].lower()
finally:
(tmp_dir / ".git").rmdir()
tmp_dir.rmdir()
def test_apply_refactor_expiry(self):
"""apply_refactor rejects expired previews."""
tmp_dir = Path(tempfile.mkdtemp())
(tmp_dir / ".git").mkdir()
try:
# Insert a preview that is already expired.
rid = "expired1"
with _refactor_lock:
_pending_refactors[rid] = {
"refactor_id": rid,
"type": "rename",
"old_name": "old",
"new_name": "new",
"edits": [],
"stats": {"high": 0, "medium": 0, "low": 0},
"created_at": time.time() - REFACTOR_EXPIRY_SECONDS - 10,
}
result = apply_refactor(rid, tmp_dir)
assert result["status"] == "error"
assert "expired" in result["error"].lower()
finally:
(tmp_dir / ".git").rmdir()
tmp_dir.rmdir()
def test_apply_refactor_path_traversal(self):
"""apply_refactor blocks edits outside repo root."""
tmp_dir = Path(tempfile.mkdtemp())
(tmp_dir / ".git").mkdir()
try:
rid = "traversal"
with _refactor_lock:
_pending_refactors[rid] = {
"refactor_id": rid,
"type": "rename",
"old_name": "old",
"new_name": "new",
"edits": [{
"file": "/etc/passwd",
"line": 1,
"old": "old",
"new": "new",
"confidence": "high",
}],
"stats": {"high": 1, "medium": 0, "low": 0},
"created_at": time.time(),
}
result = apply_refactor(rid, tmp_dir)
assert result["status"] == "error"
assert "outside repo root" in result["error"].lower()
finally:
(tmp_dir / ".git").rmdir()
tmp_dir.rmdir()
def test_apply_refactor_success(self):
"""apply_refactor applies string replacement to a real file."""
tmp_dir = Path(tempfile.mkdtemp())
(tmp_dir / ".git").mkdir()
target_file = tmp_dir / "example.py"
target_file.write_text("def old_func():\n pass\n", encoding="utf-8")
try:
rid = "success1"
with _refactor_lock:
_pending_refactors[rid] = {
"refactor_id": rid,
"type": "rename",
"old_name": "old_func",
"new_name": "new_func",
"edits": [{
"file": str(target_file),
"line": 1,
"old": "old_func",
"new": "new_func",
"confidence": "high",
}],
"stats": {"high": 1, "medium": 0, "low": 0},
"created_at": time.time(),
}
result = apply_refactor(rid, tmp_dir)
assert result["status"] == "ok"
assert result["edits_applied"] == 1
assert len(result["files_modified"]) == 1
# Verify file content was changed.
content = target_file.read_text(encoding="utf-8")
assert "new_func" in content
assert "old_func" not in content
finally:
target_file.unlink(missing_ok=True)
(tmp_dir / ".git").rmdir()
tmp_dir.rmdir()
def test_apply_refactor_dry_run_returns_diff_without_writing(self):
"""dry_run=True returns a unified diff without touching disk and
keeps the refactor_id valid for a follow-up write (#176)."""
tmp_dir = Path(tempfile.mkdtemp())
(tmp_dir / ".git").mkdir()
target_file = tmp_dir / "example.py"
original = "def old_func():\n pass\n"
target_file.write_text(original, encoding="utf-8")
try:
rid = "dryrun1"
with _refactor_lock:
_pending_refactors[rid] = {
"refactor_id": rid,
"type": "rename",
"old_name": "old_func",
"new_name": "new_func",
"edits": [{
"file": str(target_file),
"line": 1,
"old": "old_func",
"new": "new_func",
"confidence": "high",
}],
"stats": {"high": 1, "medium": 0, "low": 0},
"created_at": time.time(),
}
# Step 1: dry_run — no writes, returns diff
result = apply_refactor(rid, tmp_dir, dry_run=True)
assert result["status"] == "ok"
assert result["dry_run"] is True
assert result["edits_applied"] == 1
assert len(result["would_modify"]) == 1
assert result["files_modified"] == [] # nothing written yet
assert str(target_file) in result["would_modify"]
# Diff should mention both the old and new name
diff = result["diffs"][str(target_file)]
assert "-def old_func():" in diff
assert "+def new_func():" in diff
# File on disk must be unchanged
assert target_file.read_text(encoding="utf-8") == original
# Step 2: refactor_id should still be valid — dry_run doesn't consume it
with _refactor_lock:
assert rid in _pending_refactors
# Step 3: real apply — uses same refactor_id
real_result = apply_refactor(rid, tmp_dir, dry_run=False)
assert real_result["status"] == "ok"
assert real_result.get("dry_run") is None # not set on the real path
assert real_result["edits_applied"] == 1
assert len(real_result["files_modified"]) == 1
# File content changed
new_content = target_file.read_text(encoding="utf-8")
assert "new_func" in new_content
assert "old_func" not in new_content
# refactor_id consumed after real apply
with _refactor_lock:
assert rid not in _pending_refactors
finally:
target_file.unlink(missing_ok=True)
(tmp_dir / ".git").rmdir()
tmp_dir.rmdir()
def test_apply_refactor_dry_run_no_edits(self):
"""dry_run with an empty edit list returns an empty diff dict."""
tmp_dir = Path(tempfile.mkdtemp())
(tmp_dir / ".git").mkdir()
try:
rid = "dryrun-empty"
with _refactor_lock:
_pending_refactors[rid] = {
"refactor_id": rid,
"type": "rename",
"old_name": "x",
"new_name": "y",
"edits": [],
"stats": {"high": 0, "medium": 0, "low": 0},
"created_at": time.time(),
}
result = apply_refactor(rid, tmp_dir, dry_run=True)
assert result["status"] == "ok"
assert result["dry_run"] is True
assert result["would_modify"] == []
assert result["diffs"] == {}
finally:
with _refactor_lock:
_pending_refactors.pop("dryrun-empty", None)
(tmp_dir / ".git").rmdir()
tmp_dir.rmdir()
class TestPendingRefactorsThreadSafe:
"""Tests for thread-safety of the pending refactors storage."""
def test_pending_refactors_thread_safe(self):
"""The _refactor_lock is a threading.Lock instance."""
assert isinstance(_refactor_lock, type(threading.Lock()))
def test_concurrent_access(self):
"""Multiple threads can safely access _pending_refactors."""
results = []
def writer(rid: str):
with _refactor_lock:
_pending_refactors[rid] = {
"refactor_id": rid,
"created_at": time.time(),
}
results.append(rid)
threads = [threading.Thread(target=writer, args=(f"t{i}",)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
with _refactor_lock:
assert len(results) == 10
assert len(_pending_refactors) >= 10
# Clean up
_pending_refactors.clear()
class TestFindDeadCodeWithReferences:
"""Tests for REFERENCES-aware dead code detection."""
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self._seed()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _seed(self):
"""Seed with functions that have REFERENCES edges (map dispatch pattern)."""
# File
self.store.upsert_node(NodeInfo(
kind="File", name="/repo/handlers.ts", file_path="/repo/handlers.ts",
line_start=1, line_end=100, language="typescript",
))
# A function referenced in a map (should NOT be dead)
self.store.upsert_node(NodeInfo(
kind="Function", name="handleCreate", file_path="/repo/handlers.ts",
line_start=10, line_end=20, language="typescript",
))
# A function with CALLS edge (should NOT be dead)
self.store.upsert_node(NodeInfo(
kind="Function", name="calledFunc", file_path="/repo/handlers.ts",
line_start=30, line_end=40, language="typescript",
))
# A truly dead function (no edges at all)
self.store.upsert_node(NodeInfo(
kind="Function", name="deadFunc", file_path="/repo/handlers.ts",
line_start=50, line_end=60, language="typescript",
))
# Caller
self.store.upsert_node(NodeInfo(
kind="Function", name="dispatch", file_path="/repo/handlers.ts",
line_start=70, line_end=80, language="typescript",
))
# REFERENCES edge: dispatch -> handleCreate (map dispatch pattern)
self.store.upsert_edge(EdgeInfo(
kind="REFERENCES", source="/repo/handlers.ts::dispatch",
target="/repo/handlers.ts::handleCreate",
file_path="/repo/handlers.ts", line=75,
))
# CALLS edge: dispatch -> calledFunc
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="/repo/handlers.ts::dispatch",
target="/repo/handlers.ts::calledFunc",
file_path="/repo/handlers.ts", line=76,
))
self.store.commit()
def test_referenced_function_not_dead(self):
"""Functions with REFERENCES edges should NOT be flagged as dead code."""
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "handleCreate" not in dead_names
def test_called_function_not_dead(self):
"""Functions with CALLS edges remain excluded (existing behavior)."""
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "calledFunc" not in dead_names
def test_truly_dead_function_still_reported(self):
"""Functions with no edges at all should still be flagged as dead code."""
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "deadFunc" in dead_names
def test_only_references_edge_sufficient(self):
"""A function with ONLY a REFERENCES edge (no CALLS/IMPORTS) is not dead."""
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
# handleCreate has only a REFERENCES edge, no CALLS targeting it
assert "handleCreate" not in dead_names
class TestTransitiveImportResolution:
"""Tests for 2-hop transitive import resolution in plausible caller."""
def setup_method(self):
self.store = GraphStore(":memory:")
for f in ("/repo/consumer.ts", "/repo/lib/index.ts", "/repo/lib/utils.ts"):
self.store.upsert_node(NodeInfo(
kind="File", name=f, file_path=f,
line_start=1, line_end=50, language="typescript",
))
def test_transitive_import_via_barrel_file(self):
"""consumer.ts imports index.ts which re-exports from utils.ts.
A bare-name CALLS from consumer.ts should be plausible for utils.ts functions."""
# Function defined in utils.ts
self.store.upsert_node(NodeInfo(
kind="Function", name="safeJsonParse",
file_path="/repo/lib/utils.ts",
line_start=10, line_end=20, language="typescript",
))
# Import chain: consumer -> index -> utils
self.store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source="/repo/consumer.ts",
target="/repo/lib/index.ts", file_path="/repo/consumer.ts", line=1,
))
self.store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source="/repo/lib/index.ts",
target="/repo/lib/utils.ts", file_path="/repo/lib/index.ts", line=1,
))
# Bare-name CALLS from consumer
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="/repo/consumer.ts::processData",
target="safeJsonParse", file_path="/repo/consumer.ts", line=5,
))
self.store.commit()
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "safeJsonParse" not in dead_names, (
"2-hop import chain should make consumer a plausible caller"
)
class TestFindDeadCodeModuleScope:
"""End-to-end regression: parse → store → find_dead_code.
Pins the contract that functions invoked only from module scope are not
flagged as dead. Bypasses the hand-built graph fixtures used elsewhere in
this file so that a regression in any of the parser's 5 module-scope
CALLS paths is caught.
"""
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self.parser = CodeParser()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _store_parsed(self, path: Path, source: bytes) -> None:
nodes, edges = self.parser.parse_bytes(path, source)
for n in nodes:
self.store.upsert_node(n)
for e in edges:
self.store.upsert_edge(e)
self.store.commit()
def test_module_scope_caller_prevents_dead_code_flag(self, tmp_path):
"""A function called only from top-level script glue is not dead."""
# ``run_job`` has no non-dunder name match and no framework decorator,
# so without the module-scope CALLS fix it would be flagged dead.
path = tmp_path / "script.py"
path.write_bytes(
b"def run_job():\n"
b" return 1\n"
b"\n"
b"run_job()\n"
)
self._store_parsed(path, path.read_bytes())
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "run_job" not in dead_names, (
"module-scope caller should prevent run_job from being flagged dead"
)
def test_if_main_block_caller_prevents_dead_code_flag(self, tmp_path):
"""A function called only inside ``if __name__ == '__main__'`` is not dead."""
path = tmp_path / "cli.py"
path.write_bytes(
b"def launch():\n"
b" return 1\n"
b"\n"
b"if __name__ == '__main__':\n"
b" launch()\n"
)
self._store_parsed(path, path.read_bytes())
dead = find_dead_code(self.store)
dead_names = {d["name"] for d in dead}
assert "launch" not in dead_names
+336
View File
@@ -0,0 +1,336 @@
"""Tests for multi-repo registry and connection pool."""
import sqlite3
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from code_review_graph.registry import ConnectionPool, Registry, resolve_repo
class TestRegistry:
def setup_method(self):
self.tmp_dir = tempfile.mkdtemp()
self.registry_path = Path(self.tmp_dir) / "registry.json"
self.registry = Registry(path=self.registry_path)
# Create fake repos
self.repo1 = Path(self.tmp_dir) / "repo1"
self.repo1.mkdir()
(self.repo1 / ".git").mkdir()
self.repo2 = Path(self.tmp_dir) / "repo2"
self.repo2.mkdir()
(self.repo2 / ".code-review-graph").mkdir()
def teardown_method(self):
import shutil
shutil.rmtree(self.tmp_dir, ignore_errors=True)
def test_register_and_list(self):
"""Register repos and list them back."""
self.registry.register(str(self.repo1), alias="r1")
self.registry.register(str(self.repo2), alias="r2")
repos = self.registry.list_repos()
assert len(repos) == 2
paths = [r["path"] for r in repos]
assert str(self.repo1.resolve()) in paths
assert str(self.repo2.resolve()) in paths
def test_register_duplicate_path(self):
"""Registering the same path twice updates alias."""
self.registry.register(str(self.repo1), alias="first")
self.registry.register(str(self.repo1), alias="second")
repos = self.registry.list_repos()
assert len(repos) == 1
assert repos[0]["alias"] == "second"
def test_register_invalid_path(self):
"""Registering a non-existent path raises ValueError."""
import pytest
with pytest.raises(ValueError, match="not a directory"):
self.registry.register("/nonexistent/path/repo")
def test_register_not_a_repo(self):
"""Registering a dir without .git or .code-review-graph raises ValueError."""
import pytest
bare_dir = Path(self.tmp_dir) / "bare"
bare_dir.mkdir()
with pytest.raises(ValueError, match="does not look like a repository"):
self.registry.register(str(bare_dir))
def test_unregister_by_path(self):
"""Unregister a repo by path."""
self.registry.register(str(self.repo1), alias="r1")
assert len(self.registry.list_repos()) == 1
result = self.registry.unregister(str(self.repo1))
assert result is True
assert len(self.registry.list_repos()) == 0
def test_unregister_by_alias(self):
"""Unregister a repo by alias."""
self.registry.register(str(self.repo1), alias="myalias")
assert len(self.registry.list_repos()) == 1
result = self.registry.unregister("myalias")
assert result is True
assert len(self.registry.list_repos()) == 0
def test_unregister_not_found(self):
"""Unregistering a non-registered repo returns False."""
result = self.registry.unregister("nonexistent")
assert result is False
def test_find_by_alias(self):
"""find_by_alias returns correct entry."""
self.registry.register(str(self.repo1), alias="myrepo")
entry = self.registry.find_by_alias("myrepo")
assert entry is not None
assert entry["alias"] == "myrepo"
assert entry["path"] == str(self.repo1.resolve())
def test_find_by_alias_not_found(self):
"""find_by_alias returns None for unknown alias."""
entry = self.registry.find_by_alias("nope")
assert entry is None
def test_find_by_path(self):
"""find_by_path returns correct entry."""
self.registry.register(str(self.repo1), alias="r1")
entry = self.registry.find_by_path(str(self.repo1))
assert entry is not None
assert entry["path"] == str(self.repo1.resolve())
def test_persistence(self):
"""Registry persists to disk and reloads correctly."""
self.registry.register(str(self.repo1), alias="persistent")
# Create a new registry from the same file
registry2 = Registry(path=self.registry_path)
repos = registry2.list_repos()
assert len(repos) == 1
assert repos[0]["alias"] == "persistent"
def test_resolve_by_alias(self):
"""resolve_repo resolves alias to path."""
self.registry.register(str(self.repo1), alias="r1")
result = resolve_repo(self.registry, "r1")
assert result == str(self.repo1.resolve())
def test_resolve_by_direct_path(self):
"""resolve_repo resolves direct path."""
result = resolve_repo(self.registry, str(self.repo1))
assert result == str(self.repo1.resolve())
def test_resolve_by_cwd(self):
"""resolve_repo falls back to cwd when repo is None."""
result = resolve_repo(self.registry, None, cwd=str(self.repo1))
assert result == str(self.repo1.resolve())
def test_resolve_returns_none(self):
"""resolve_repo returns None when nothing matches."""
result = resolve_repo(self.registry, None)
assert result is None
class TestConnectionPool:
def setup_method(self):
self.tmp_dir = tempfile.mkdtemp()
self.pool = ConnectionPool(max_size=3)
def teardown_method(self):
self.pool.close_all()
import shutil
shutil.rmtree(self.tmp_dir, ignore_errors=True)
def _make_db(self, name: str) -> str:
"""Create a temporary SQLite database file."""
db_path = str(Path(self.tmp_dir) / f"{name}.db")
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE IF NOT EXISTS test (id INTEGER)")
conn.close()
return db_path
def test_get_creates_connection(self):
"""get() creates a new connection."""
db_path = self._make_db("test1")
conn = self.pool.get(db_path)
assert conn is not None
assert self.pool.size == 1
def test_get_reuses_connection(self):
"""get() returns the same connection for the same path."""
db_path = self._make_db("test1")
conn1 = self.pool.get(db_path)
conn2 = self.pool.get(db_path)
assert conn1 is conn2
assert self.pool.size == 1
def test_eviction_on_full(self):
"""Pool evicts LRU connection when full."""
db1 = self._make_db("db1")
db2 = self._make_db("db2")
db3 = self._make_db("db3")
db4 = self._make_db("db4")
self.pool.get(db1)
self.pool.get(db2)
self.pool.get(db3)
assert self.pool.size == 3
# Adding 4th should evict db1 (LRU)
self.pool.get(db4)
assert self.pool.size == 3
def test_close_all(self):
"""close_all() clears all connections."""
db1 = self._make_db("db1")
db2 = self._make_db("db2")
self.pool.get(db1)
self.pool.get(db2)
assert self.pool.size == 2
self.pool.close_all()
assert self.pool.size == 0
def test_lru_ordering(self):
"""Recently used connections are kept over stale ones."""
db1 = self._make_db("db1")
db2 = self._make_db("db2")
db3 = self._make_db("db3")
db4 = self._make_db("db4")
conn1 = self.pool.get(db1)
self.pool.get(db2)
self.pool.get(db3)
# Access db1 again to make it recently used
self.pool.get(db1)
# Now add db4 — db2 should be evicted (LRU), not db1
self.pool.get(db4)
assert self.pool.size == 3
# db1 should still be in pool
conn1_again = self.pool.get(db1)
assert conn1_again is conn1
class TestCrossRepoSearch:
def test_cross_repo_search_no_repos(self):
"""cross_repo_search with empty registry returns empty results."""
from code_review_graph.tools import cross_repo_search_func
tmp_dir = tempfile.mkdtemp()
with patch("code_review_graph.registry.Registry") as mock_registry_cls:
mock_instance = MagicMock()
mock_instance.list_repos.return_value = []
mock_registry_cls.return_value = mock_instance
result = cross_repo_search_func(query="test")
assert result["status"] == "ok"
assert result["results"] == []
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
class TestSetDataDir:
"""Tests for set_data_dir and get_data_dir_for_repo methods."""
def setup_method(self):
"""Set up isolated test registry."""
self.tmp_dir = tempfile.mkdtemp()
self.registry_path = Path(self.tmp_dir) / "registry.json"
self.registry = Registry(path=self.registry_path)
def teardown_method(self):
"""Clean up temporary directory."""
import shutil
shutil.rmtree(self.tmp_dir, ignore_errors=True)
def test_set_data_dir_new_repo(self):
"""set_data_dir should create new registry entry if repo not registered."""
repo = Path(self.tmp_dir) / "project"
repo.mkdir()
data_dir = Path(self.tmp_dir) / "data"
entry = self.registry.set_data_dir(str(repo), str(data_dir))
assert entry["path"] == str(repo.resolve())
assert entry["data_dir"] == str(data_dir.resolve())
# Verify it can be retrieved
retrieved = self.registry.get_data_dir_for_repo(str(repo))
assert retrieved == str(data_dir.resolve())
# Verify entry is in list
repos = self.registry.list_repos()
assert len(repos) == 1
assert repos[0]["path"] == str(repo.resolve())
def test_set_data_dir_existing_repo(self):
"""set_data_dir should update data_dir for already registered repo."""
repo = Path(self.tmp_dir) / "project"
repo.mkdir()
data_dir1 = Path(self.tmp_dir) / "data1"
data_dir2 = Path(self.tmp_dir) / "data2"
# Initial registration
entry1 = self.registry.set_data_dir(str(repo), str(data_dir1))
assert entry1["data_dir"] == str(data_dir1.resolve())
# Update with new data_dir
entry2 = self.registry.set_data_dir(str(repo), str(data_dir2))
assert entry2["data_dir"] == str(data_dir2.resolve())
# Verify only one entry exists
repos = self.registry.list_repos()
assert len(repos) == 1
def test_get_data_dir_for_repo_unknown(self):
"""get_data_dir_for_repo should return None for unknown repo."""
unknown_repo = Path(self.tmp_dir) / "unknown"
result = self.registry.get_data_dir_for_repo(str(unknown_repo))
assert result is None
def test_set_data_dir_with_alias(self):
"""register() with data_dir should store both."""
repo = Path(self.tmp_dir) / "project"
repo.mkdir()
(repo / ".git").mkdir()
data_dir = Path(self.tmp_dir) / "data"
alias = "my-project"
entry = self.registry.register(str(repo), alias=alias, data_dir=str(data_dir))
assert entry["path"] == str(repo.resolve())
assert entry["alias"] == alias
assert entry["data_dir"] == str(data_dir.resolve())
def test_backward_compatibility(self):
"""Old registry entries without data_dir should work."""
repo = Path(self.tmp_dir) / "project"
repo.mkdir()
# Create entry without data_dir (old format)
self.registry._repos.append({
"path": str(repo.resolve()),
"alias": "old-project"
})
self.registry._save()
# Should not crash
result = self.registry.get_data_dir_for_repo(str(repo))
assert result is None
# Should be able to add data_dir
data_dir = Path(self.tmp_dir) / "data"
entry = self.registry.set_data_dir(str(repo), str(data_dir))
assert entry["data_dir"] == str(data_dir.resolve())
+270
View File
@@ -0,0 +1,270 @@
"""Tests for the hybrid search engine."""
import tempfile
from pathlib import Path
from code_review_graph.graph import GraphStore
from code_review_graph.parser import NodeInfo
from code_review_graph.search import (
detect_query_kind_boost,
hybrid_search,
rebuild_fts_index,
rrf_merge,
)
class TestHybridSearch:
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self._seed_data()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
def _seed_data(self):
"""Seed test nodes into the graph store."""
nodes = [
NodeInfo(
kind="Function", name="get_users", file_path="api.py",
line_start=1, line_end=20, language="python",
params="(db: Session)", return_type="list[User]",
),
NodeInfo(
kind="Function", name="create_user", file_path="api.py",
line_start=25, line_end=40, language="python",
params="(name: str, email: str)", return_type="User",
),
NodeInfo(
kind="Class", name="UserService", file_path="services.py",
line_start=1, line_end=100, language="python",
),
NodeInfo(
kind="Function", name="authenticate", file_path="auth.py",
line_start=5, line_end=30, language="python",
params="(token: str)", return_type="bool",
),
NodeInfo(
kind="Type", name="UserResponse", file_path="models.py",
line_start=1, line_end=15, language="python",
),
]
for node in nodes:
node_id = self.store.upsert_node(node, file_hash="abc123")
# Set signature for functions
if node.kind == "Function":
sig = f"def {node.name}{node.params or '()'} -> {node.return_type or 'None'}"
self.store._conn.execute(
"UPDATE nodes SET signature = ? WHERE id = ?", (sig, node_id)
)
self.store._conn.commit()
# --- rebuild_fts_index ---
def test_rebuild_fts_index(self):
"""rebuild_fts_index returns the correct count of indexed rows."""
count = rebuild_fts_index(self.store)
assert count == 5
def test_rebuild_fts_index_idempotent(self):
"""Rebuilding twice gives the same count."""
count1 = rebuild_fts_index(self.store)
count2 = rebuild_fts_index(self.store)
assert count1 == count2
# --- FTS search by name ---
def test_fts_search_by_name(self):
"""FTS search finds a node by its name."""
rebuild_fts_index(self.store)
results = hybrid_search(self.store, "get_users")
assert len(results) > 0
names = [r["name"] for r in results]
assert "get_users" in names
# --- FTS search by signature ---
def test_fts_search_by_signature(self):
"""FTS search finds a node by content in its signature."""
rebuild_fts_index(self.store)
results = hybrid_search(self.store, "Session")
assert len(results) > 0
# get_users has "Session" in its signature
names = [r["name"] for r in results]
assert "get_users" in names
# --- Kind boosting ---
def test_kind_boost_pascal_case(self):
"""PascalCase query boosts Class kind > 1.0."""
boosts = detect_query_kind_boost("UserService")
assert "Class" in boosts
assert boosts["Class"] > 1.0
def test_kind_boost_snake_case(self):
"""snake_case query boosts Function kind > 1.0."""
boosts = detect_query_kind_boost("get_users")
assert "Function" in boosts
assert boosts["Function"] > 1.0
def test_kind_boost_dotted(self):
"""Dotted query boosts qualified name matches."""
boosts = detect_query_kind_boost("api.get_users")
assert "_qualified" in boosts
assert boosts["_qualified"] > 1.0
def test_kind_boost_empty(self):
"""Empty query returns no boosts."""
boosts = detect_query_kind_boost("")
assert boosts == {}
def test_kind_boost_all_uppercase(self):
"""ALL_CAPS should not trigger PascalCase boost."""
boosts = detect_query_kind_boost("HTTP_STATUS")
assert "Class" not in boosts
# But should trigger snake_case boost
assert "Function" in boosts
# --- RRF merge ---
def test_rrf_merge(self):
"""Node appearing in both lists ranks highest after RRF merge."""
list_a = [(1, 10.0), (2, 8.0), (3, 6.0)]
list_b = [(2, 9.0), (4, 7.0), (1, 5.0)]
merged = rrf_merge(list_a, list_b)
ids = [item_id for item_id, _ in merged]
# Items 1 and 2 appear in both lists, so they should be top-ranked
assert ids[0] in (1, 2)
assert ids[1] in (1, 2)
# ID 2 is rank 0+0 in list_b and rank 1 in list_a
# ID 1 is rank 0 in list_a and rank 2 in list_b
# So ID 2 should rank higher: 1/(60+1+1) + 1/(60+0+1) vs 1/(60+0+1) + 1/(60+2+1)
assert ids[0] == 2
def test_rrf_merge_single_list(self):
"""RRF merge with a single list preserves order."""
single = [(10, 5.0), (20, 3.0), (30, 1.0)]
merged = rrf_merge(single)
ids = [item_id for item_id, _ in merged]
assert ids == [10, 20, 30]
def test_rrf_merge_empty(self):
"""RRF merge with empty lists returns empty."""
merged = rrf_merge([], [])
assert merged == []
# --- Fallback to keyword search ---
def test_fallback_to_keyword(self):
"""Works without FTS index by falling back to keyword LIKE matching."""
# Do NOT rebuild FTS index — drop it if it exists
try:
self.store._conn.execute("DROP TABLE IF EXISTS nodes_fts")
self.store._conn.commit()
except Exception:
pass
results = hybrid_search(self.store, "authenticate")
assert len(results) > 0
names = [r["name"] for r in results]
assert "authenticate" in names
# --- Empty query ---
def test_empty_query_handled(self):
"""Empty query returns empty results without crashing."""
results = hybrid_search(self.store, "")
assert results == []
def test_whitespace_query_handled(self):
"""Whitespace-only query returns empty results."""
results = hybrid_search(self.store, " ")
assert results == []
# --- Return fields ---
def test_hybrid_search_returns_expected_fields(self):
"""All expected fields are present in search results."""
rebuild_fts_index(self.store)
results = hybrid_search(self.store, "get_users")
assert len(results) > 0
expected_fields = {
"name", "qualified_name", "kind", "file_path",
"line_start", "line_end", "language", "params",
"return_type", "signature", "score",
}
for result in results:
assert expected_fields.issubset(result.keys()), (
f"Missing fields: {expected_fields - result.keys()}"
)
# --- Kind filtering ---
def test_kind_filter(self):
"""Kind parameter filters results to only that kind."""
rebuild_fts_index(self.store)
results = hybrid_search(self.store, "User", kind="Class")
for r in results:
assert r["kind"] == "Class"
# --- Context file boosting ---
def test_context_file_boost(self):
"""Nodes in context_files get boosted above others."""
rebuild_fts_index(self.store)
# Search for "user" which matches multiple nodes
results_with_ctx = hybrid_search(
self.store, "user", context_files=["api.py"]
)
# Find get_users in both result sets
if results_with_ctx:
api_nodes = [r for r in results_with_ctx if r["file_path"] == "api.py"]
if api_nodes:
# api.py nodes should have a score boost
api_score = api_nodes[0]["score"]
assert api_score > 0
# --- Limit parameter ---
def test_limit_respected(self):
"""Search respects the limit parameter."""
rebuild_fts_index(self.store)
results = hybrid_search(self.store, "user", limit=2)
assert len(results) <= 2
# --- FTS5 injection safety ---
def test_fts_query_with_special_chars(self):
"""FTS5 special characters are safely handled."""
rebuild_fts_index(self.store)
# These should not crash — FTS5 operators like AND, OR, NOT, *, etc.
for dangerous_query in ['OR user', 'NOT thing', 'user*', '"user"', 'a AND b']:
results = hybrid_search(self.store, dangerous_query)
# Just assert no exception was raised
assert isinstance(results, list)
def test_fts_rebuild_is_atomic(self):
"""Regression test for #259: rebuild_fts_index must wrap the DROP +
CREATE + INSERT sequence in a single transaction so a crash between
DROP and CREATE cannot leave the DB without an FTS table."""
# Build, rebuild, then verify the table exists and is queryable.
rebuild_fts_index(self.store)
# Verify the FTS table exists and has rows.
conn = self.store._conn
count = conn.execute("SELECT count(*) FROM nodes_fts").fetchone()[0]
assert count > 0
# Rebuild again — must not raise and must leave the table intact.
new_count = rebuild_fts_index(self.store)
assert new_count == count
# Verify search still works after double-rebuild.
results = hybrid_search(self.store, "auth")
assert isinstance(results, list)
+1649
View File
File diff suppressed because it is too large Load Diff
+1565
View File
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
"""Tests for SQLite transaction robustness and nesting scenarios."""
import sqlite3
import tempfile
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
from code_review_graph.graph import GraphStore
from code_review_graph.parser import NodeInfo, EdgeInfo
from code_review_graph.communities import store_communities
from code_review_graph.flows import store_flows
@pytest.fixture
def store():
"""Create a temporary GraphStore for testing."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
db_path = tmp.name
store = GraphStore(db_path)
yield store
store.close()
Path(db_path).unlink(missing_ok=True)
class TestTransactionRobustness:
def test_nested_transaction_guard_in_store_file(self, store, caplog):
"""Test that store_file_nodes_edges handles an already open transaction."""
# Manually open a transaction
store._conn.execute("BEGIN")
store._conn.execute("INSERT INTO metadata (key, value) VALUES (?, ?)", ("test", "val"))
assert store._conn.in_transaction
# This should trigger the guard, rollback the uncommitted insert, and start a new transaction
with caplog.at_level(logging.WARNING):
store.store_file_nodes_edges("test.py", [], [])
assert "Rolling back uncommitted transaction before BEGIN IMMEDIATE" in caplog.text
assert not store._conn.in_transaction
# Verify the "val" was rolled back
assert store.get_metadata("test") is None
def test_atomic_community_storage(self, store):
"""Test that store_communities is atomic and handles existing transactions."""
communities = [
{"name": "comm1", "size": 1, "members": ["node1"]}
]
# Leave a transaction open
store._conn.execute("BEGIN")
store._conn.execute("INSERT INTO metadata (key, value) VALUES ('leak', 'stale')")
# Should rollback the 'leak' and successfully store communities
store_communities(store, communities)
assert store.get_metadata("leak") is None
# Verify communities table
count = store._conn.execute("SELECT count(*) FROM communities").fetchone()[0]
assert count == 1
def test_atomic_flow_storage(self, store):
"""Test that store_flows is atomic and handles existing transactions."""
flows = [
{
"name": "flow1", "entry_point_id": 1, "depth": 1,
"node_count": 1, "file_count": 1, "criticality": 0.5,
"path": [1]
}
]
# Leave a transaction open
store._conn.execute("BEGIN")
store._conn.execute("INSERT INTO metadata (key, value) VALUES ('leak', 'stale')")
# Should rollback and store flows
store_flows(store, flows)
assert store.get_metadata("leak") is None
count = store._conn.execute("SELECT count(*) FROM flows").fetchone()[0]
assert count == 1
def test_rollback_on_failure_in_batch_ops(self, store):
"""Verify that store_file_nodes_edges rolls back if an operation fails inside."""
# Pre-seed some data
node_keep = NodeInfo(
kind="File", name="keep", file_path="keep.py",
line_start=1, line_end=10, language="python"
)
store.store_file_nodes_edges("keep.py", [node_keep], [])
# Attempt to store new file but force a failure
node_fail = NodeInfo(
kind="File", name="fail", file_path="fail.py",
line_start=1, line_end=10, language="python"
)
with patch.object(store, 'upsert_node', side_effect=Exception("Simulated failure")):
with pytest.raises(Exception, match="Simulated failure"):
store.store_file_nodes_edges("fail.py", [node_fail], [])
# Verify 'fail.py' data is NOT present
assert len(store.get_nodes_by_file("fail.py")) == 0
# Verify 'keep.py' data IS still present
assert len(store.get_nodes_by_file("keep.py")) == 1
def test_public_rollback_api(self, store):
"""Verify the new GraphStore.rollback() public method works."""
store._conn.execute("BEGIN")
store._conn.execute("INSERT INTO metadata (key, value) VALUES ('rollback', 'me')")
assert store._conn.in_transaction
store.rollback()
assert not store._conn.in_transaction
assert store.get_metadata("rollback") is None
+56
View File
@@ -0,0 +1,56 @@
"""Tests for the TsconfigResolver class."""
from __future__ import annotations
import tempfile
from pathlib import Path
from code_review_graph.tsconfig_resolver import TsconfigResolver
FIXTURES = Path(__file__).parent / "fixtures"
class TestTsconfigResolver:
def setup_method(self):
self.resolver = TsconfigResolver()
def test_strip_jsonc_comments(self):
text = '{\n // comment\n "key": "value" /* block */\n}'
result = self.resolver._strip_jsonc_comments(text)
assert "//" not in result
assert "/*" not in result
def test_strip_trailing_commas(self):
text = '{"a": 1, "b": 2,}'
result = self.resolver._strip_jsonc_comments(text)
assert ",}" not in result
def test_resolve_alias(self):
importer = str(FIXTURES / "alias_importer.ts")
result = self.resolver.resolve_alias("@/lib/utils", importer)
assert result is not None
assert result.endswith("utils.ts")
def test_resolve_alias_nonexistent_returns_none(self):
importer = str(FIXTURES / "alias_importer.ts")
result = self.resolver.resolve_alias("@/nonexistent/module", importer)
assert result is None
def test_resolve_npm_package_returns_none(self):
importer = str(FIXTURES / "alias_importer.ts")
result = self.resolver.resolve_alias("react", importer)
assert result is None
def test_no_tsconfig_returns_none(self):
with tempfile.TemporaryDirectory() as tmp_dir:
file_path = str(Path(tmp_dir) / "file.ts")
result = self.resolver.resolve_alias("@/foo", file_path)
assert result is None
def test_caching(self):
importer = str(FIXTURES / "alias_importer.ts")
self.resolver.resolve_alias("@/lib/utils", importer)
cache_size_after_first = len(self.resolver._cache)
assert cache_size_after_first >= 1
self.resolver.resolve_alias("@/lib/utils", importer)
assert len(self.resolver._cache) == cache_size_after_first
+578
View File
@@ -0,0 +1,578 @@
"""Tests for graph visualization export."""
import json
import pytest
from code_review_graph.graph import GraphStore
from code_review_graph.parser import EdgeInfo, NodeInfo
@pytest.fixture
def store_with_data(tmp_path):
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
file_node = NodeInfo(
kind="File",
name="auth.py",
file_path="src/auth.py",
line_start=1,
line_end=50,
language="python",
parent_name=None,
params=None,
return_type=None,
modifiers=None,
is_test=False,
extra={},
)
class_node = NodeInfo(
kind="Class",
name="AuthService",
file_path="src/auth.py",
line_start=5,
line_end=45,
language="python",
parent_name=None,
params=None,
return_type=None,
modifiers=None,
is_test=False,
extra={},
)
func_node = NodeInfo(
kind="Function",
name="login",
file_path="src/auth.py",
line_start=10,
line_end=20,
language="python",
parent_name="AuthService",
params="username, password",
return_type="bool",
modifiers=None,
is_test=False,
extra={},
)
test_file = NodeInfo(
kind="File",
name="test_auth.py",
file_path="tests/test_auth.py",
line_start=1,
line_end=10,
language="python",
parent_name=None,
params=None,
return_type=None,
modifiers=None,
is_test=False,
extra={},
)
test_node = NodeInfo(
kind="Test",
name="test_login",
file_path="tests/test_auth.py",
line_start=1,
line_end=10,
language="python",
parent_name=None,
params=None,
return_type=None,
modifiers=None,
is_test=True,
extra={},
)
store.upsert_node(file_node)
store.upsert_node(class_node)
store.upsert_node(func_node)
store.upsert_node(test_file)
store.upsert_node(test_node)
contains_edge = EdgeInfo(
kind="CONTAINS",
source="src/auth.py",
target="src/auth.py::AuthService",
file_path="src/auth.py",
line=5,
extra={},
)
calls_edge = EdgeInfo(
kind="CALLS",
source="tests/test_auth.py::test_login",
target="src/auth.py::AuthService.login",
file_path="tests/test_auth.py",
line=5,
extra={},
)
store.upsert_edge(contains_edge)
store.upsert_edge(calls_edge)
store.commit()
return store
def test_export_graph_data(store_with_data):
from code_review_graph.visualization import export_graph_data
data = export_graph_data(store_with_data)
assert "nodes" in data
assert "edges" in data
assert "stats" in data
assert len(data["nodes"]) == 5
assert len(data["edges"]) == 2
node_names = {n["name"] for n in data["nodes"]}
assert "auth.py" in node_names
assert "AuthService" in node_names
assert "login" in node_names
edge_kinds = {e["kind"] for e in data["edges"]}
assert "CONTAINS" in edge_kinds
assert "CALLS" in edge_kinds
json.dumps(data) # must be serializable
def test_generate_html(store_with_data, tmp_path):
from code_review_graph.visualization import generate_html
output_path = tmp_path / "graph.html"
generate_html(store_with_data, output_path)
assert output_path.exists()
content = output_path.read_text()
assert "d3js.org" in content or "d3.v7" in content
assert "auth.py" in content
assert "AuthService" in content
assert "<!DOCTYPE html>" in content
assert "</html>" in content
def test_cpp_include_resolution(tmp_path):
"""IMPORTS_FROM edges with bare C++ include paths should resolve to File nodes
stored under absolute paths — previously these were dropped, leaving the
graph almost entirely disconnected for C/C++ projects."""
from code_review_graph.visualization import export_graph_data
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
def _file(name, path, lang="cpp"):
return NodeInfo(
kind="File", name=name, file_path=path,
line_start=1, line_end=10, language=lang,
parent_name=None, params=None, return_type=None,
modifiers=None, is_test=False, extra={},
)
store.upsert_node(_file("main.cpp", "/abs/src/main.cpp"))
store.upsert_node(_file("Renderer.hpp", "/abs/libs/rendering/Renderer.hpp"))
store.upsert_node(_file("Utils.hpp", "/abs/libs/utils/Utils.hpp"))
# Parser emits bare include paths as targets — exactly what Tree-sitter sees
store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM",
source="/abs/src/main.cpp",
target="rendering/Renderer.hpp", # relative, one directory level
file_path="/abs/src/main.cpp", line=1, extra={},
))
store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM",
source="/abs/src/main.cpp",
target="Utils.hpp", # bare filename only
file_path="/abs/src/main.cpp", line=2, extra={},
))
store.commit()
data = export_graph_data(store)
resolved_targets = {e["target"] for e in data["edges"] if e["kind"] == "IMPORTS_FROM"}
assert "/abs/libs/rendering/Renderer.hpp" in resolved_targets, (
"bare relative include 'rendering/Renderer.hpp' was not resolved to its absolute path"
)
assert "/abs/libs/utils/Utils.hpp" in resolved_targets, (
"bare filename include 'Utils.hpp' was not resolved to its absolute path"
)
def test_generate_html_overwrites(store_with_data, tmp_path):
from code_review_graph.visualization import generate_html
output_path = tmp_path / "graph.html"
output_path.write_text("old content")
generate_html(store_with_data, output_path)
content = output_path.read_text()
assert "old content" not in content
assert "<!DOCTYPE html>" in content
def test_export_includes_flows(store_with_data):
"""Export data should include a 'flows' key (list, possibly empty)."""
from code_review_graph.visualization import export_graph_data
data = export_graph_data(store_with_data)
assert "flows" in data
assert isinstance(data["flows"], list)
def test_export_includes_communities(store_with_data):
"""Export data should include a 'communities' key (list, possibly empty)."""
from code_review_graph.visualization import export_graph_data
data = export_graph_data(store_with_data)
assert "communities" in data
assert isinstance(data["communities"], list)
def test_generate_html_includes_all_edge_types(store_with_data, tmp_path):
"""Generated HTML should define colors and legend entries for all 7 edge types."""
from code_review_graph.visualization import generate_html
output_path = tmp_path / "graph.html"
generate_html(store_with_data, output_path)
content = output_path.read_text()
for edge_kind in ["CALLS", "IMPORTS_FROM", "INHERITS", "CONTAINS",
"IMPLEMENTS", "TESTED_BY", "DEPENDS_ON"]:
assert edge_kind in content, f"Edge type {edge_kind} missing from HTML"
def test_generate_html_includes_interactive_features(store_with_data, tmp_path):
"""Generated HTML should include new interactive features."""
from code_review_graph.visualization import generate_html
output_path = tmp_path / "graph.html"
generate_html(store_with_data, output_path)
content = output_path.read_text()
# Detail panel
assert "detail-panel" in content
# Community coloring button
assert "btn-community" in content
# Flow dropdown
assert "flow-select" in content
# Filter panel
assert "filter-panel" in content
# Search results dropdown
assert "search-results" in content
# Accessibility: skip link
assert "skip-link" in content
# Accessibility: live region
assert 'aria-live="polite"' in content
# Node shapes mapping
assert "KIND_SHAPE" in content
def test_generate_html_includes_node_shapes(store_with_data, tmp_path):
"""Generated HTML should use d3.symbol() for distinct node shapes."""
from code_review_graph.visualization import generate_html
output_path = tmp_path / "graph.html"
generate_html(store_with_data, output_path)
content = output_path.read_text()
assert "d3.symbol()" in content or "symbolCircle" in content
assert "symbolSquare" in content
assert "symbolTriangle" in content
assert "symbolDiamond" in content
assert "symbolCross" in content
def test_generate_html_includes_help_overlay(store_with_data, tmp_path):
"""Generated HTML should include a help overlay for onboarding."""
from code_review_graph.visualization import generate_html
output_path = tmp_path / "graph.html"
generate_html(store_with_data, output_path)
content = output_path.read_text()
assert "help-overlay" in content
assert "btn-help" in content
assert "Click a file" in content
def test_generate_html_includes_aria_attributes(store_with_data, tmp_path):
"""Generated HTML should include key ARIA attributes for accessibility."""
from code_review_graph.visualization import generate_html
output_path = tmp_path / "graph.html"
generate_html(store_with_data, output_path)
content = output_path.read_text()
assert 'role="tooltip"' in content
assert 'role="dialog"' in content
assert 'role="listbox"' in content
assert 'aria-pressed="false"' in content # community button
assert 'aria-modal="false"' in content # detail panel
def test_generate_html_includes_loading_and_empty_state(store_with_data, tmp_path):
"""Generated HTML should include loading overlay and empty state markup."""
from code_review_graph.visualization import generate_html
output_path = tmp_path / "graph.html"
generate_html(store_with_data, output_path)
content = output_path.read_text()
assert "loading-overlay" in content
assert "empty-state" in content
assert "No nodes to display" in content
def test_generate_html_includes_focus_visible(store_with_data, tmp_path):
"""Generated HTML should include :focus-visible styles."""
from code_review_graph.visualization import generate_html
output_path = tmp_path / "graph.html"
generate_html(store_with_data, output_path)
content = output_path.read_text()
assert ":focus-visible" in content
# ---------------------------------------------------------------------------
# Phase 9: Visualization Aggregation
# ---------------------------------------------------------------------------
@pytest.fixture
def large_store(tmp_path):
"""Store with enough nodes/communities to test aggregation."""
db_path = tmp_path / "large.db"
store = GraphStore(db_path)
# Create nodes across multiple files (simulates a larger codebase)
files = [f"src/mod{i}.py" for i in range(5)]
for fp in files:
file_node = NodeInfo(
kind="File", name=fp.split("/")[-1], file_path=fp,
line_start=1, line_end=100, language="python",
parent_name=None, params=None, return_type=None,
modifiers=None, is_test=False, extra={},
)
store.upsert_node(file_node)
# Add some functions per file
for j in range(3):
func_node = NodeInfo(
kind="Function", name=f"func_{j}",
file_path=fp, line_start=10 + j * 10, line_end=20 + j * 10,
language="python", parent_name=None,
params="x", return_type="int",
modifiers=None, is_test=False, extra={},
)
store.upsert_node(func_node)
# CONTAINS edge from file to function
store.upsert_edge(EdgeInfo(
kind="CONTAINS", source=fp,
target=f"{fp}::func_{j}",
file_path=fp, line=10 + j * 10, extra={},
))
# Add some cross-file CALLS edges
store.upsert_edge(EdgeInfo(
kind="CALLS",
source="src/mod0.py::func_0",
target="src/mod1.py::func_1",
file_path="src/mod0.py", line=15, extra={},
))
store.upsert_edge(EdgeInfo(
kind="CALLS",
source="src/mod2.py::func_0",
target="src/mod3.py::func_2",
file_path="src/mod2.py", line=12, extra={},
))
store.upsert_edge(EdgeInfo(
kind="CALLS",
source="src/mod1.py::func_2",
target="src/mod4.py::func_0",
file_path="src/mod1.py", line=35, extra={},
))
# Set community_id on nodes (simulate community detection)
store._conn.execute(
"UPDATE nodes SET community_id = 0 WHERE file_path IN ('src/mod0.py', 'src/mod1.py')"
)
store._conn.execute(
"UPDATE nodes SET community_id = 1 WHERE file_path IN ('src/mod2.py', 'src/mod3.py')"
)
store._conn.execute(
"UPDATE nodes SET community_id = 2 WHERE file_path = 'src/mod4.py'"
)
# Create communities table and insert communities
store._conn.execute("""
CREATE TABLE IF NOT EXISTS communities (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
level INTEGER DEFAULT 0,
cohesion REAL DEFAULT 0.0,
size INTEGER DEFAULT 0,
dominant_language TEXT DEFAULT '',
description TEXT DEFAULT ''
)
""")
store._conn.execute("""
CREATE TABLE IF NOT EXISTS community_members (
community_id INTEGER, node_id INTEGER,
FOREIGN KEY (community_id) REFERENCES communities(id)
)
""")
store._conn.execute(
"INSERT INTO communities (id, name, level, cohesion, size, dominant_language, description) "
"VALUES (0, 'Core Module', 0, 0.8, 8, 'python', 'Core functionality')"
)
store._conn.execute(
"INSERT INTO communities (id, name, level, cohesion, size, dominant_language, description) "
"VALUES (1, 'Data Module', 0, 0.7, 8, 'python', 'Data processing')"
)
store._conn.execute(
"INSERT INTO communities (id, name, level, cohesion, size, dominant_language, description) "
"VALUES (2, 'Utils', 0, 0.5, 4, 'python', 'Utility functions')"
)
# Insert community_members so get_communities works
for row in store._conn.execute(
"SELECT id, qualified_name, community_id FROM nodes WHERE community_id IS NOT NULL"
).fetchall():
store._conn.execute(
"INSERT INTO community_members (community_id, node_id) VALUES (?, ?)",
(row["community_id"], row["id"]),
)
store.commit()
return store
def test_community_mode_fewer_nodes(large_store, tmp_path):
"""Community mode should produce fewer nodes than full mode."""
from code_review_graph.visualization import (
_aggregate_community,
export_graph_data,
)
data = export_graph_data(large_store)
full_node_count = len(data["nodes"])
agg = _aggregate_community(data)
community_node_count = len(agg["nodes"])
assert community_node_count < full_node_count, (
f"Community mode ({community_node_count} nodes) should have fewer nodes "
f"than full mode ({full_node_count} nodes)"
)
# All aggregated nodes should be of kind "Community"
for n in agg["nodes"]:
assert n["kind"] == "Community"
# Edges should be CROSS_COMMUNITY type
for e in agg["edges"]:
assert e["kind"] == "CROSS_COMMUNITY"
# Should have community_details for drill-down
assert "community_details" in agg
assert len(agg["community_details"]) > 0
def test_file_mode_aggregation(large_store, tmp_path):
"""File mode should produce one node per file."""
from code_review_graph.visualization import (
_aggregate_file,
export_graph_data,
)
data = export_graph_data(large_store)
full_node_count = len(data["nodes"])
agg = _aggregate_file(data)
file_node_count = len(agg["nodes"])
assert file_node_count < full_node_count, (
f"File mode ({file_node_count} nodes) should have fewer nodes "
f"than full mode ({full_node_count} nodes)"
)
# All nodes should be of kind "File"
for n in agg["nodes"]:
assert n["kind"] == "File"
# Edges should be DEPENDS_ON type
for e in agg["edges"]:
assert e["kind"] == "DEPENDS_ON"
# Mode should be set
assert agg["mode"] == "file"
def test_auto_mode_switches_at_threshold(large_store, tmp_path):
"""Auto mode should switch to community when nodes exceed threshold."""
from code_review_graph.visualization import generate_html
output_path = tmp_path / "auto_low.html"
# Threshold higher than node count -> should use full template
generate_html(large_store, output_path, mode="auto", max_full_nodes=100000)
content = output_path.read_text()
# Full template has btn-community and flow-select
assert "btn-community" in content
assert "flow-select" in content
output_path2 = tmp_path / "auto_high.html"
# Threshold of 1 -> should switch to community mode
generate_html(large_store, output_path2, mode="auto", max_full_nodes=1)
content2 = output_path2.read_text()
# Aggregated template has btn-back and community_details
assert "btn-back" in content2
assert "community_details" in content2
def test_community_mode_html_generation(large_store, tmp_path):
"""Community mode generates valid HTML with aggregated data."""
from code_review_graph.visualization import generate_html
output_path = tmp_path / "community.html"
generate_html(large_store, output_path, mode="community")
content = output_path.read_text()
assert "<!DOCTYPE html>" in content
assert "</html>" in content
assert "btn-back" in content
assert "community_details" in content
assert "drillIntoCommunity" in content
def test_file_mode_html_generation(large_store, tmp_path):
"""File mode generates valid HTML with file-level data."""
from code_review_graph.visualization import generate_html
output_path = tmp_path / "file.html"
generate_html(large_store, output_path, mode="file")
content = output_path.read_text()
assert "<!DOCTYPE html>" in content
assert "</html>" in content
assert "DEPENDS_ON" in content
def test_full_mode_backward_compatible(store_with_data, tmp_path):
"""Full mode should produce identical output to the original 2-arg call."""
from code_review_graph.visualization import generate_html
# Original 2-arg call (backward compat)
output1 = tmp_path / "compat.html"
generate_html(store_with_data, output1)
content1 = output1.read_text()
assert "btn-community" in content1
assert "flow-select" in content1
# Explicit full mode
output2 = tmp_path / "full.html"
generate_html(store_with_data, output2, mode="full")
content2 = output2.read_text()
assert "btn-community" in content2
assert "flow-select" in content2
def test_community_detail_data_complete(large_store):
"""Each community's detail data should contain its member nodes."""
from code_review_graph.visualization import (
_aggregate_community,
export_graph_data,
)
data = export_graph_data(large_store)
agg = _aggregate_community(data)
for cid_str, detail in agg["community_details"].items():
assert "nodes" in detail
assert "edges" in detail
# Detail nodes should exist
assert isinstance(detail["nodes"], list)
assert isinstance(detail["edges"], list)
# All original nodes should appear in exactly one community detail
all_detail_qns = set()
for detail in agg["community_details"].values():
for n in detail["nodes"]:
all_detail_qns.add(n["qualified_name"])
original_qns = {n["qualified_name"] for n in data["nodes"]}
assert original_qns == all_detail_qns, (
"All original nodes should be accounted for in community details"
)
+254
View File
@@ -0,0 +1,254 @@
"""Tests for wiki generation."""
import tempfile
from pathlib import Path
from code_review_graph.communities import detect_communities, store_communities
from code_review_graph.graph import GraphStore
from code_review_graph.parser import EdgeInfo, NodeInfo
from code_review_graph.wiki import (
_generate_community_page,
_slugify,
generate_wiki,
get_wiki_page,
)
class TestWiki:
def setup_method(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.store = GraphStore(self.tmp.name)
self.wiki_dir = tempfile.mkdtemp()
def teardown_method(self):
self.store.close()
Path(self.tmp.name).unlink(missing_ok=True)
# Clean up wiki dir
wiki_path = Path(self.wiki_dir)
if wiki_path.exists():
for f in wiki_path.iterdir():
f.unlink(missing_ok=True)
wiki_path.rmdir()
def _seed_communities(self):
"""Seed graph data and detect/store communities."""
# Auth cluster
self.store.upsert_node(
NodeInfo(
kind="File", name="auth.py", file_path="auth.py",
line_start=1, line_end=100, language="python",
), file_hash="a1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="login", file_path="auth.py",
line_start=5, line_end=20, language="python",
), file_hash="a1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="logout", file_path="auth.py",
line_start=25, line_end=40, language="python",
), file_hash="a1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="check_token", file_path="auth.py",
line_start=45, line_end=60, language="python",
), file_hash="a1"
)
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="auth.py::login",
target="auth.py::check_token", file_path="auth.py", line=10,
))
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="auth.py::logout",
target="auth.py::check_token", file_path="auth.py", line=30,
))
# DB cluster
self.store.upsert_node(
NodeInfo(
kind="File", name="db.py", file_path="db.py",
line_start=1, line_end=100, language="python",
), file_hash="b1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="connect", file_path="db.py",
line_start=5, line_end=20, language="python",
), file_hash="b1"
)
self.store.upsert_node(
NodeInfo(
kind="Function", name="query", file_path="db.py",
line_start=25, line_end=40, language="python",
), file_hash="b1"
)
self.store.upsert_edge(EdgeInfo(
kind="CALLS", source="db.py::query",
target="db.py::connect", file_path="db.py", line=30,
))
self.store.commit()
communities = detect_communities(self.store, min_size=2)
store_communities(self.store, communities)
return communities
def test_generate_wiki_creates_files(self):
"""generate_wiki creates markdown files including index.md."""
self._seed_communities()
result = generate_wiki(self.store, self.wiki_dir)
wiki_path = Path(self.wiki_dir)
assert (wiki_path / "index.md").exists()
# At least one community page should be generated
md_files = list(wiki_path.glob("*.md"))
assert len(md_files) >= 2 # index + at least 1 community page
assert result["pages_generated"] >= 2
assert isinstance(result["pages_updated"], int)
assert isinstance(result["pages_unchanged"], int)
def test_generate_wiki_index_has_links(self):
"""index.md contains links to community pages."""
self._seed_communities()
generate_wiki(self.store, self.wiki_dir)
index_content = (Path(self.wiki_dir) / "index.md").read_text()
assert "# Code Wiki" in index_content
assert "Communities" in index_content
assert ".md" in index_content # contains links to .md files
def test_get_wiki_page_returns_content(self):
"""get_wiki_page returns content for an existing page."""
self._seed_communities()
generate_wiki(self.store, self.wiki_dir)
# Find any generated page
wiki_path = Path(self.wiki_dir)
pages = [f for f in wiki_path.glob("*.md") if f.name != "index.md"]
assert len(pages) > 0
# Get page by its stem (slug)
page_name = pages[0].stem
content = get_wiki_page(self.wiki_dir, page_name)
assert content is not None
assert len(content) > 0
def test_get_wiki_page_returns_none_for_missing(self):
"""get_wiki_page returns None for non-existent page."""
content = get_wiki_page(self.wiki_dir, "nonexistent-page")
assert content is None
def test_community_page_has_expected_sections(self):
"""Generated community pages contain expected sections."""
communities = self._seed_communities()
assert len(communities) > 0
from code_review_graph.communities import get_communities
stored = get_communities(self.store)
assert len(stored) > 0
page = _generate_community_page(self.store, stored[0])
assert "## Overview" in page
assert "## Members" in page
assert "## Execution Flows" in page
assert "## Dependencies" in page
def test_slugify(self):
"""_slugify converts names to safe filenames."""
assert _slugify("auth-login") == "auth-login"
assert _slugify("My Community Name") == "my-community-name"
assert _slugify("") == "unnamed"
assert _slugify("auth/sub-cluster") == "auth-sub-cluster"
def test_generate_wiki_force_regenerates(self):
"""generate_wiki with force=True regenerates all pages."""
self._seed_communities()
# First generation
result1 = generate_wiki(self.store, self.wiki_dir)
assert result1["pages_generated"] >= 2
# Second generation without force - should be unchanged
result2 = generate_wiki(self.store, self.wiki_dir)
assert result2["pages_unchanged"] >= 1
# Third generation with force - should update all
result3 = generate_wiki(self.store, self.wiki_dir, force=True)
assert result3["pages_generated"] + result3["pages_updated"] >= 1
def test_generate_wiki_empty_graph(self):
"""generate_wiki on empty graph creates index with no communities."""
result = generate_wiki(self.store, self.wiki_dir)
assert result["pages_generated"] >= 1 # at least index.md
index_content = (Path(self.wiki_dir) / "index.md").read_text()
assert "Total communities" in index_content
assert "0" in index_content # 0 communities
def test_generate_wiki_handles_slug_collisions(self, monkeypatch):
"""Communities whose names slugify to the same string must each get
their own page — earlier behaviour silently overwrote the first
community's file with the second's content and counted the second
as an 'updated' page (see #222 follow-up).
"""
# Fake three communities whose _slugify outputs collide:
# "Data Processing" -> data-processing
# "data processing" -> data-processing
# "Data Processing" -> data-processing
colliding_communities = [
{
"name": "Data Processing", "size": 5, "cohesion": 0.9,
"dominant_language": "python", "description": "first",
"members": [], "member_qns": set(),
},
{
"name": "data processing", "size": 4, "cohesion": 0.8,
"dominant_language": "python", "description": "second",
"members": [], "member_qns": set(),
},
{
"name": "Data Processing", "size": 3, "cohesion": 0.7,
"dominant_language": "python", "description": "third",
"members": [], "member_qns": set(),
},
]
import code_review_graph.wiki as wiki_mod
monkeypatch.setattr(
wiki_mod, "get_communities", lambda store: colliding_communities,
)
result = generate_wiki(self.store, self.wiki_dir)
# 3 unique .md pages + 1 index.md should land on disk.
wiki_files = sorted(p.name for p in Path(self.wiki_dir).glob("*.md"))
expected = {
"data-processing.md",
"data-processing-2.md",
"data-processing-3.md",
"index.md",
}
assert set(wiki_files) == expected, wiki_files
# Counter must match what actually hit the disk.
page_total = (
result["pages_generated"]
+ result["pages_updated"]
+ result["pages_unchanged"]
)
assert page_total == len(wiki_files), (
f"counter {result} but {len(wiki_files)} files on disk"
)
# Every community's description must survive (no data loss).
content_first = (Path(self.wiki_dir) / "data-processing.md").read_text()
content_second = (Path(self.wiki_dir) / "data-processing-2.md").read_text()
content_third = (Path(self.wiki_dir) / "data-processing-3.md").read_text()
# Descriptions are rendered in the page body; make sure all three
# communities produced distinct content.
assert content_first != content_second
assert content_first != content_third
assert content_second != content_third