chore: import upstream snapshot with attribution
This commit is contained in:
Vendored
+47
@@ -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;
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export function MarkdownMsg() {
|
||||
return <div />;
|
||||
}
|
||||
Vendored
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+66
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+78
@@ -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; }
|
||||
}
|
||||
Vendored
+72
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { UserService } from './sample_typescript';
|
||||
|
||||
export function formatUser(name: string): string {
|
||||
return cn('user', name);
|
||||
}
|
||||
Vendored
+8
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -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", "")
|
||||
Vendored
+30
@@ -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
|
||||
}
|
||||
Vendored
+25
@@ -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;
|
||||
}
|
||||
Vendored
+30
@@ -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;
|
||||
}
|
||||
Vendored
+42
@@ -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);
|
||||
}
|
||||
Vendored
+36
@@ -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
|
||||
Vendored
+41
@@ -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
|
||||
Vendored
+22
@@ -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;
|
||||
}
|
||||
Vendored
+67
@@ -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
|
||||
Vendored
+27
@@ -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
|
||||
}
|
||||
Vendored
+139
@@ -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,
|
||||
}
|
||||
Vendored
+119
@@ -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,
|
||||
}
|
||||
Vendored
+47
@@ -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;
|
||||
}
|
||||
Vendored
+17
@@ -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; };
|
||||
});
|
||||
}
|
||||
Vendored
+100
@@ -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");
|
||||
}
|
||||
}
|
||||
Vendored
+33
@@ -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";
|
||||
}
|
||||
Vendored
+38
@@ -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
|
||||
Vendored
+79
@@ -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)
|
||||
}
|
||||
Vendored
+27
@@ -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"
|
||||
Vendored
+37
@@ -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
|
||||
Vendored
+43
@@ -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 "$@"
|
||||
Vendored
+218
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+37
@@ -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;
|
||||
$$;
|
||||
Vendored
+77
@@ -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
|
||||
Vendored
+60
@@ -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
|
||||
}
|
||||
Vendored
+32
@@ -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
|
||||
Vendored
+27
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Vendored
+48
@@ -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
|
||||
}
|
||||
Vendored
+7
@@ -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
@@ -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
@@ -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({});
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -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');
|
||||
});
|
||||
});
|
||||
Vendored
+12
@@ -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;
|
||||
};
|
||||
}
|
||||
Vendored
+91
@@ -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
|
||||
}
|
||||
Vendored
+51
@@ -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)
|
||||
Vendored
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+41
@@ -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' });
|
||||
}
|
||||
}
|
||||
Vendored
+18
@@ -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();
|
||||
});
|
||||
});
|
||||
Vendored
+35
@@ -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>
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export function cn(...args: string[]): string {
|
||||
return args.join(' ');
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
library(testthat)
|
||||
|
||||
test_that("addition works", {
|
||||
expect_equal(add(1, 2), 3)
|
||||
})
|
||||
|
||||
test_add <- function() {
|
||||
stopifnot(add(1, 2) == 3)
|
||||
}
|
||||
Vendored
+19
@@ -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"
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@utils/*": ["src/lib/utils/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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%)"
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
+1565
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user