mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-23 00:41:36 +00:00
* fix(group): contract extractors honour .gitnexusignore via shared IgnoreService (#1185) The HTTP, gRPC, and topic contract extractors each globbed the repo with a hardcoded `ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/vendor/**']` array, bypassing the shared `IgnoreService` that the rest of the ingestion pipeline uses for `.gitnexusignore` and `.gitignore` parsing. Result: a vendored Python venv (`mentor_env/`), generated stubs, or any user-defined exclusion silently produced false-positive contracts. Replace each hardcoded array with `createIgnoreFilter(repoPath)`, mirroring the canonical pattern in `filesystem-walker.ts`. The 5 hardcoded names are all in `DEFAULT_IGNORE_LIST`, so default behaviour is preserved; users now also get `.gitnexusignore` patterns, the rest of the hardcoded list (e.g. `__pycache__`, `.pytest_cache`), and the `.gitnexusignore` negation semantics introduced in #771. The topic extractor additionally filters Go `*_test.go` at the glob level. That filter is preserved via a small wrapper around `createIgnoreFilter` that short-circuits before delegating, so glob-level pruning still applies and the existing `_test.go` skip test (with new content asserting the pruning is real) still passes. Tests added to all three `*-extractor.test.ts` files exercising `.gitnexusignore` honouring end-to-end via real temp directories. * test(group): exercise gRPC source-scan ignore + add .gitignore-only coverage (#1185) Addresses two findings from the @claude review on PR #1247: [medium] The gRPC ignore test claimed to cover both proto-context and source-scan paths but only wrote a .proto file under mentor_env/. Added a Python `_pb2_grpc.<Name>Stub(channel)` consumer file under the same ignored dir (mirroring the canonical pattern from `test_extract_python_stub_returns_consumer`); without the `.gitnexusignore` filter that file would emit a consumer contract. The test now exercises both `createIgnoreFilter` calls inside the gRPC extractor (`buildProtoContext` + `extract`) in a single run, with both defence-in-depth path-prefix assertions and a specific `role: consumer` LeakedService assertion. [low] Added one shared .gitignore-only test on the HTTP extractor. `createIgnoreFilter` reads both `.gitignore` and `.gitnexusignore` via `loadIgnoreRules`, but no extractor-level test exercised the `.gitignore` path. One shared test is sufficient because all three extractors consume the same filter object — verified at `IgnoreService` level already. The remaining [low] finding — "negation semantics (!pattern) not tested at extractor level" — is deferred deliberately, not skipped. Three reasons: 1. The negation logic (introduced in #771) lives entirely inside `createIgnoreFilter`'s `hasExplicitUnignore` ancestor-walk in `ignore-service.ts`. The extractors only consume the returned filter object — they never inspect patterns, never call `hasExplicitUnignore` directly, and have no code path that could diverge from the IgnoreService's negation behaviour. 2. Negation is already locked in by 8 dedicated unit tests in `test/unit/ignore-service.test.ts` (the #771 suite), plus the `!parent/` + `parent/child/` last-match-wins regression test added in PR #1046. An extractor-level negation test would re-prove the same code path and would not catch any failure mode the existing tests don't already catch. 3. The bot itself flagged the gap as "Acceptable to leave as follow-up referencing existing IgnoreService negation tests" — the deferral matches its own recommendation. If a future change inserts an extractor-side wrapper around the filter (as topic-extractor.ts already does for `*_test.go`) that could plausibly affect negation, an extractor-level negation test should be added at that point — not pre-emptively here.
818 lines
29 KiB
TypeScript
818 lines
29 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js';
|
|
import type { RepoHandle } from '../../../src/core/group/types.js';
|
|
|
|
describe('HttpRouteExtractor', () => {
|
|
const tmpDir = path.join(os.tmpdir(), `gitnexus-http-extract-${Date.now()}`);
|
|
let extractor: HttpRouteExtractor;
|
|
|
|
beforeEach(() => {
|
|
extractor = new HttpRouteExtractor();
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
const makeRepo = (repoPath: string): RepoHandle => ({
|
|
id: 'test-repo',
|
|
path: 'test/backend',
|
|
repoPath,
|
|
storagePath: path.join(repoPath, '.gitnexus'),
|
|
});
|
|
|
|
describe('provider extraction — graph-first (Strategy A)', () => {
|
|
it('extracts routes from Route/HANDLES_ROUTE graph + source scan for method', async () => {
|
|
const dir = path.join(tmpDir, 'graph-first');
|
|
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/controller/UserController.java'),
|
|
`
|
|
@RestController
|
|
@RequestMapping("/api/v2")
|
|
public class UserController {
|
|
@GetMapping("/users")
|
|
public List<User> list() { return service.findAll(); }
|
|
|
|
@PostMapping("/users")
|
|
public User create(@RequestBody User user) { return service.save(user); }
|
|
}
|
|
`,
|
|
);
|
|
|
|
const mockDbExecutor = async (query: string) => {
|
|
if (query.includes('HANDLES_ROUTE')) {
|
|
return [
|
|
{
|
|
fileId: 'file-uid-ctrl',
|
|
filePath: 'src/controller/UserController.java',
|
|
routePath: '/api/v2/users',
|
|
routeId: 'route-uid-users',
|
|
responseKeys: null,
|
|
routeSource: 'decorator-GetMapping',
|
|
},
|
|
];
|
|
}
|
|
if (query.includes('CONTAINS')) {
|
|
return [
|
|
{
|
|
uid: 'uid-ctrl-list',
|
|
name: 'list',
|
|
filePath: 'src/controller/UserController.java',
|
|
labels: ['Method'],
|
|
},
|
|
{
|
|
uid: 'uid-ctrl-create',
|
|
name: 'create',
|
|
filePath: 'src/controller/UserController.java',
|
|
labels: ['Method'],
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
|
|
const getRoute = providers.find((c) => c.contractId === 'http::GET::/api/v2/users');
|
|
expect(getRoute).toBeDefined();
|
|
expect(getRoute!.confidence).toBe(0.9);
|
|
expect(getRoute!.symbolUid).not.toBe('file-uid-ctrl');
|
|
});
|
|
});
|
|
|
|
describe('provider extraction — source-scan fallback (Strategy B)', () => {
|
|
it('extracts Spring @GetMapping annotation', async () => {
|
|
const dir = path.join(tmpDir, 'spring');
|
|
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/controller/UserController.java'),
|
|
`
|
|
package com.example;
|
|
import org.springframework.web.bind.annotation.*;
|
|
|
|
@RestController
|
|
@RequestMapping("/api/v2")
|
|
public class UserController {
|
|
@GetMapping("/users")
|
|
public List<User> list() { return service.findAll(); }
|
|
|
|
@PostMapping("/users")
|
|
public User create(@RequestBody User user) { return service.save(user); }
|
|
|
|
@GetMapping("/users/{id}")
|
|
public User getById(@PathVariable Long id) { return service.findById(id); }
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
|
|
expect(providers.length).toBeGreaterThanOrEqual(3);
|
|
|
|
const listRoute = providers.find((c) => c.contractId === 'http::GET::/api/v2/users');
|
|
expect(listRoute).toBeDefined();
|
|
expect(listRoute!.meta.method).toBe('GET');
|
|
expect(listRoute!.meta.path).toBe('/api/v2/users');
|
|
|
|
const createRoute = providers.find((c) => c.contractId === 'http::POST::/api/v2/users');
|
|
expect(createRoute).toBeDefined();
|
|
|
|
const getByIdRoute = providers.find(
|
|
(c) => c.contractId === 'http::GET::/api/v2/users/{param}',
|
|
);
|
|
expect(getByIdRoute).toBeDefined();
|
|
});
|
|
|
|
it('extracts Express router.get patterns', async () => {
|
|
const dir = path.join(tmpDir, 'express');
|
|
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/routes/users.ts'),
|
|
`
|
|
import { Router } from 'express';
|
|
const router = Router();
|
|
|
|
router.get('/api/users', async (req, res) => { res.json([]); });
|
|
router.post('/api/users', async (req, res) => { res.json({}); });
|
|
router.delete('/api/users/:id', async (req, res) => { res.sendStatus(204); });
|
|
|
|
export default router;
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
|
|
expect(providers.length).toBeGreaterThanOrEqual(3);
|
|
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
|
|
expect(providers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined();
|
|
expect(
|
|
providers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('extracts Go Gin and Echo route registrations', async () => {
|
|
const dir = path.join(tmpDir, 'go-frameworks');
|
|
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'cmd', 'server.go'),
|
|
`
|
|
package main
|
|
|
|
func createOrder(c *gin.Context) {}
|
|
func listOrders(c echo.Context) error { return nil }
|
|
|
|
func main() {
|
|
r := gin.Default()
|
|
r.POST("/api/orders/:id", createOrder)
|
|
|
|
e := echo.New()
|
|
e.GET("/api/orders", listOrders)
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
|
|
const ginRoute = providers.find((c) => c.contractId === 'http::POST::/api/orders/{param}');
|
|
expect(ginRoute).toBeDefined();
|
|
expect(ginRoute?.symbolName).toBe('createOrder');
|
|
|
|
const echoRoute = providers.find((c) => c.contractId === 'http::GET::/api/orders');
|
|
expect(echoRoute).toBeDefined();
|
|
expect(echoRoute?.symbolName).toBe('listOrders');
|
|
});
|
|
|
|
it('extracts stdlib HandleFunc providers', async () => {
|
|
const dir = path.join(tmpDir, 'go-stdlib-provider');
|
|
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'cmd', 'server.go'),
|
|
`
|
|
package main
|
|
|
|
func healthHandler(w http.ResponseWriter, r *http.Request) {}
|
|
|
|
func main() {
|
|
http.HandleFunc("/api/health", healthHandler)
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
|
|
const healthRoute = providers.find((c) => c.contractId === 'http::GET::/api/health');
|
|
expect(healthRoute).toBeDefined();
|
|
expect(healthRoute?.symbolName).toBe('healthHandler');
|
|
});
|
|
|
|
it('extracts NestJS controller decorators', async () => {
|
|
const dir = path.join(tmpDir, 'nestjs');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'orders.controller.ts'),
|
|
`
|
|
import { Controller, Patch } from '@nestjs/common';
|
|
|
|
@Controller('orders')
|
|
export class OrdersController {
|
|
@Patch(':id')
|
|
updateOrder() {
|
|
return {};
|
|
}
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
|
|
const patchRoute = providers.find((c) => c.contractId === 'http::PATCH::/orders/{param}');
|
|
expect(patchRoute).toBeDefined();
|
|
expect(patchRoute?.symbolName).toBe('updateOrder');
|
|
});
|
|
});
|
|
|
|
describe('consumer extraction — fetch patterns', () => {
|
|
it('extracts fetch() calls', async () => {
|
|
const dir = path.join(tmpDir, 'frontend');
|
|
fs.mkdirSync(path.join(dir, 'src/api'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/api/users.ts'),
|
|
`
|
|
export async function fetchUsers() {
|
|
const res = await fetch('/api/users');
|
|
return res.json();
|
|
}
|
|
|
|
export async function createUser(data: any) {
|
|
const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });
|
|
return res.json();
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(consumers.length).toBeGreaterThanOrEqual(2);
|
|
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
|
|
expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined();
|
|
});
|
|
|
|
it('extracts axios calls', async () => {
|
|
const dir = path.join(tmpDir, 'axios-fe');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/api.ts'),
|
|
`
|
|
import axios from 'axios';
|
|
export const getUsers = () => axios.get('/api/users');
|
|
export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`);
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
|
|
expect(
|
|
consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('extracts jQuery $.get and $.post shorthand', async () => {
|
|
const dir = path.join(tmpDir, 'jquery-shorthand');
|
|
fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'public/js/users.js'),
|
|
`
|
|
function loadUsers() {
|
|
$.get('/api/users', function (data) { console.log(data); });
|
|
}
|
|
|
|
function createUser(payload) {
|
|
$.post('/api/users', payload);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
const getRoute = consumers.find((c) => c.contractId === 'http::GET::/api/users');
|
|
expect(getRoute).toBeDefined();
|
|
expect(getRoute?.meta.framework).toBe('jquery');
|
|
|
|
const postRoute = consumers.find((c) => c.contractId === 'http::POST::/api/users');
|
|
expect(postRoute).toBeDefined();
|
|
expect(postRoute?.meta.framework).toBe('jquery');
|
|
});
|
|
|
|
it('extracts jQuery $.ajax with method: and type: keys and default GET', async () => {
|
|
const dir = path.join(tmpDir, 'jquery-ajax');
|
|
fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'public/js/orders.js'),
|
|
`
|
|
$.ajax({ url: '/api/orders', method: 'PUT', data: {} });
|
|
$.ajax({ url: '/api/items', type: 'DELETE' });
|
|
$.ajax({ url: '/api/default' });
|
|
|
|
function reloadOrder(id) {
|
|
return $.ajax({ url: \`/api/orders/\${id}\`, method: 'GET' });
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(consumers.find((c) => c.contractId === 'http::PUT::/api/orders')).toBeDefined();
|
|
expect(consumers.find((c) => c.contractId === 'http::DELETE::/api/items')).toBeDefined();
|
|
expect(consumers.find((c) => c.contractId === 'http::GET::/api/default')).toBeDefined();
|
|
// Template-literal URL inside $.ajax is normalized to {param} the same
|
|
// way the fetch/axios paths do — confirms readStringProp accepts
|
|
// template_string values for jQuery ajax, not just for axios object form.
|
|
expect(
|
|
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('extracts axios({ method, url }) object form regardless of key order', async () => {
|
|
const dir = path.join(tmpDir, 'axios-object');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/orders.ts'),
|
|
`
|
|
import axios from 'axios';
|
|
|
|
export function createOrder(data: unknown) {
|
|
return axios({ method: 'POST', url: '/api/orders', data });
|
|
}
|
|
|
|
export function updateUser(id: string, data: unknown) {
|
|
return axios({ url: \`/api/users/\${id}\`, method: 'PUT', data });
|
|
}
|
|
|
|
export function listDefaults() {
|
|
return axios({ url: '/api/defaults' });
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined();
|
|
expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined();
|
|
expect(consumers.find((c) => c.contractId === 'http::GET::/api/defaults')).toBeDefined();
|
|
});
|
|
|
|
it('does not emit consumers for unrelated object-literal calls (negative control)', async () => {
|
|
const dir = path.join(tmpDir, 'jquery-axios-negative');
|
|
fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'public/js/misc.js'),
|
|
`
|
|
// jQuery but not an ajax/get/post call
|
|
$.fn.extend({ url: '/nope', method: 'POST' });
|
|
$.each([1, 2, 3], function (i, v) { return v; });
|
|
|
|
// Not axios and not $ — unrelated helper that happens to take { url, method }
|
|
function myHelper(opts) { return opts; }
|
|
myHelper({ url: '/nope', method: 'POST' });
|
|
|
|
// Bare object literal, not a call argument at all
|
|
const cfg = { url: '/nope', method: 'POST' };
|
|
console.log(cfg);
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
// None of the above should have produced any HTTP consumer contracts.
|
|
const nopeConsumers = consumers.filter(
|
|
(c) => typeof c.meta.path === 'string' && c.meta.path.includes('/nope'),
|
|
);
|
|
expect(nopeConsumers).toHaveLength(0);
|
|
});
|
|
|
|
it('extracts Python requests calls', async () => {
|
|
const dir = path.join(tmpDir, 'python-consumer');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'client.py'),
|
|
`
|
|
import requests
|
|
|
|
def create_order():
|
|
return requests.post("https://svc.local/api/orders/42", json={"id": 42})
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(
|
|
consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => {
|
|
const dir = path.join(tmpDir, 'java-consumer');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src', 'ApiClient.java'),
|
|
`
|
|
import org.springframework.http.HttpMethod;
|
|
import org.springframework.web.client.RestTemplate;
|
|
import org.springframework.web.reactive.function.client.WebClient;
|
|
import okhttp3.Request;
|
|
|
|
class ApiClient {
|
|
void run(RestTemplate restTemplate, WebClient webClient) {
|
|
restTemplate.getForObject("/api/users/{id}", String.class, 42);
|
|
webClient.method(HttpMethod.PATCH, "/api/users/42");
|
|
new Request.Builder().url("/api/orders/42").build();
|
|
}
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined();
|
|
expect(
|
|
consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'),
|
|
).toBeDefined();
|
|
expect(
|
|
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('extracts Go stdlib and resty calls', async () => {
|
|
const dir = path.join(tmpDir, 'go-consumer');
|
|
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'cmd', 'client.go'),
|
|
`
|
|
package main
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
)
|
|
|
|
func main() {
|
|
http.Get("/api/health")
|
|
client := resty.New()
|
|
client.R().Delete("/api/orders/42")
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(consumers.find((c) => c.contractId === 'http::GET::/api/health')).toBeDefined();
|
|
expect(
|
|
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
|
|
).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('provider extraction — Laravel', () => {
|
|
it('extracts Laravel Route::get patterns', async () => {
|
|
const dir = path.join(tmpDir, 'laravel');
|
|
fs.mkdirSync(path.join(dir, 'routes'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'routes/api.php'),
|
|
`<?php
|
|
Route::get('/users', [UserController::class, 'index']);
|
|
Route::post('/users', [UserController::class, 'store']);
|
|
Route::delete('/users/{id}', [UserController::class, 'destroy']);
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
|
|
expect(providers.length).toBeGreaterThanOrEqual(3);
|
|
expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined();
|
|
expect(providers.find((c) => c.contractId === 'http::POST::/users')).toBeDefined();
|
|
expect(providers.find((c) => c.contractId === 'http::DELETE::/users/{param}')).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('consumer extraction — PHP', () => {
|
|
it('extracts Laravel Http facade calls', async () => {
|
|
const dir = path.join(tmpDir, 'php-http-facade');
|
|
fs.mkdirSync(path.join(dir, 'app'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'app/Client.php'),
|
|
`<?php
|
|
use Illuminate\\Support\\Facades\\Http;
|
|
|
|
class Client {
|
|
public function run() {
|
|
Http::get('/api/users');
|
|
Http::post('/api/orders/42');
|
|
Http::delete('/api/users/7');
|
|
}
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
|
|
expect(
|
|
consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
|
|
).toBeDefined();
|
|
expect(
|
|
consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('extracts Guzzle $client->method() calls', async () => {
|
|
const dir = path.join(tmpDir, 'php-guzzle');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/ApiClient.php'),
|
|
`<?php
|
|
use GuzzleHttp\\Client;
|
|
|
|
class ApiClient {
|
|
public function run(Client $client) {
|
|
$client->get('/api/health');
|
|
$client->post('/api/orders/42');
|
|
}
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(consumers.find((c) => c.contractId === 'http::GET::/api/health')).toBeDefined();
|
|
expect(
|
|
consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('extracts file_get_contents HTTP calls', async () => {
|
|
const dir = path.join(tmpDir, 'php-fgc');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/fetch.php'),
|
|
`<?php
|
|
function fetchRemote() {
|
|
$data = file_get_contents('https://example.test/api/items/1');
|
|
$local = file_get_contents('/tmp/local-file.txt');
|
|
return $data;
|
|
}
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(consumers.find((c) => c.contractId === 'http::GET::/api/items/{param}')).toBeDefined();
|
|
// file paths and stream wrappers must not emit consumer contracts
|
|
expect(consumers.find((c) => c.meta.path === '/tmp/local-file.txt')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('provider extraction — FastAPI', () => {
|
|
it('extracts FastAPI @app.get decorator patterns', async () => {
|
|
const dir = path.join(tmpDir, 'fastapi');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/main.py'),
|
|
`from fastapi import FastAPI
|
|
app = FastAPI()
|
|
|
|
@app.get("/users")
|
|
async def list_users():
|
|
return []
|
|
|
|
@app.post("/users")
|
|
async def create_user(user: UserCreate):
|
|
return user
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
|
|
expect(providers.length).toBeGreaterThanOrEqual(2);
|
|
expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined();
|
|
expect(providers.find((c) => c.contractId === 'http::POST::/users')).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('consumer extraction — graph-first (Strategy A)', () => {
|
|
it('extracts consumers from FETCHES graph edges', async () => {
|
|
const dir = path.join(tmpDir, 'graph-consumers');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'src/api.ts'), 'export const api = {};');
|
|
|
|
const mockDbExecutor = async (query: string) => {
|
|
if (query.includes('HANDLES_ROUTE')) return [];
|
|
if (query.includes('FETCHES')) {
|
|
return [
|
|
{
|
|
fileId: 'file-uid-api',
|
|
filePath: 'src/api.ts',
|
|
routePath: '/api/users',
|
|
routeId: 'route-uid-users',
|
|
fetchReason: 'fetch-url-match',
|
|
},
|
|
];
|
|
}
|
|
if (query.includes('CONTAINS')) {
|
|
return [
|
|
{
|
|
uid: 'uid-fn-fetch',
|
|
name: 'fetchUsers',
|
|
filePath: 'src/api.ts',
|
|
labels: ['Function'],
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
|
|
const consumers = contracts.filter((c) => c.role === 'consumer');
|
|
|
|
expect(consumers.length).toBeGreaterThanOrEqual(1);
|
|
expect(consumers[0].confidence).toBe(0.9);
|
|
expect(consumers[0].symbolName).toBe('fetchUsers');
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('returns empty for repo with no matching files', async () => {
|
|
const dir = path.join(tmpDir, 'empty-repo');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'README.md'), '# Hello');
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
expect(contracts).toHaveLength(0);
|
|
});
|
|
|
|
it('handles graph queries that throw gracefully', async () => {
|
|
const dir = path.join(tmpDir, 'graph-error');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'src/routes.ts'), `router.get('/api/health', handler);`);
|
|
|
|
const throwingExecutor = async () => {
|
|
throw new Error('DB unavailable');
|
|
};
|
|
|
|
const contracts = await extractor.extract(throwingExecutor, dir, makeRepo(dir));
|
|
// Should fall back to source scan
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
expect(providers.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe('path normalization', () => {
|
|
it('strips trailing slash', async () => {
|
|
const dir = path.join(tmpDir, 'trailing');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/router.ts'),
|
|
`
|
|
router.get('/api/users/', handler);
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const provider = contracts.find((c) => c.role === 'provider');
|
|
expect(provider?.meta.path).toBe('/api/users');
|
|
});
|
|
|
|
it('normalizes path params from multiple syntaxes', async () => {
|
|
const dir = path.join(tmpDir, 'params');
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/router.ts'),
|
|
`
|
|
router.get('/api/users/:id', handler1);
|
|
router.get('/api/posts/{postId}', handler2);
|
|
`,
|
|
);
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
contracts.forEach((c) => {
|
|
expect(c.meta.path).not.toContain(':id');
|
|
expect(c.meta.path).not.toContain('{postId}');
|
|
if (typeof c.meta.path === 'string' && c.meta.path.includes('users/')) {
|
|
expect(c.meta.path).toContain('{param}');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── #1185: contract extractors must honour .gitnexusignore ─────────
|
|
//
|
|
// Pre-#1185 the source-scan path used a hardcoded
|
|
// `[node_modules, .git, dist, build, vendor]` glob ignore array, so a
|
|
// user's `.gitnexusignore` pattern (e.g. a Python venv `mentor_env/`,
|
|
// a generated stubs dir, a noisy fixture tree) was silently scanned
|
|
// anyway. Since #1185 the source-scan path consumes the shared
|
|
// `IgnoreService` (mirrors `filesystem-walker.ts`), so any pattern in
|
|
// `.gitnexusignore` (or `.gitignore`) prunes the glob.
|
|
describe('respects .gitnexusignore (#1185)', () => {
|
|
it('source-scan glob skips files matched by .gitnexusignore', async () => {
|
|
const dir = path.join(tmpDir, 'gitnexusignore-honoured');
|
|
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
|
|
fs.mkdirSync(path.join(dir, 'mentor_env/lib'), { recursive: true });
|
|
// Control: a normal route file that SHOULD be discovered.
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/routes/users.ts'),
|
|
`import { Router } from 'express';
|
|
const router = Router();
|
|
router.get('/api/users', (req, res) => res.json([]));
|
|
export default router;
|
|
`,
|
|
);
|
|
// Vendored source under a venv-style dir: the same Express
|
|
// pattern, but inside a directory the user wants excluded.
|
|
fs.writeFileSync(
|
|
path.join(dir, 'mentor_env/lib/leaked.ts'),
|
|
`import { Router } from 'express';
|
|
const r = Router();
|
|
r.get('/api/leaked', (req, res) => res.json([]));
|
|
export default r;
|
|
`,
|
|
);
|
|
fs.writeFileSync(path.join(dir, '.gitnexusignore'), 'mentor_env/\n');
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
// Control survives.
|
|
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
|
|
// Excluded path is pruned at the glob level — nothing emitted.
|
|
expect(providers.find((c) => c.contractId === 'http::GET::/api/leaked')).toBeUndefined();
|
|
// Defence-in-depth: no contract whose symbolRef is under mentor_env/.
|
|
expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
|
|
});
|
|
|
|
// Pinned by the @claude review on PR #1247: above, only `.gitnexusignore`
|
|
// is exercised. `createIgnoreFilter` reads `.gitignore` too via
|
|
// `loadIgnoreRules`, but that integration is only proven at the
|
|
// `IgnoreService` level — no extractor-level test for the
|
|
// `.gitignore`-only code path. Adding one minimal extractor-level
|
|
// assertion here closes the gap (one shared test is sufficient
|
|
// because all three extractors consume the same filter object).
|
|
it('source-scan glob also skips files matched by `.gitignore` (no `.gitnexusignore`)', async () => {
|
|
const dir = path.join(tmpDir, 'gitignore-honoured');
|
|
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
|
|
fs.mkdirSync(path.join(dir, 'mentor_env/lib'), { recursive: true });
|
|
// Same Express pattern as above so detection logic is identical.
|
|
fs.writeFileSync(
|
|
path.join(dir, 'src/routes/users.ts'),
|
|
`import { Router } from 'express';
|
|
const router = Router();
|
|
router.get('/api/users', (req, res) => res.json([]));
|
|
export default router;
|
|
`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(dir, 'mentor_env/lib/leaked.ts'),
|
|
`import { Router } from 'express';
|
|
const r = Router();
|
|
r.get('/api/leaked', (req, res) => res.json([]));
|
|
export default r;
|
|
`,
|
|
);
|
|
// Note: NO .gitnexusignore — only `.gitignore`. This proves the
|
|
// `.gitignore` code path inside `createIgnoreFilter` is wired to
|
|
// the extractors' globs.
|
|
fs.writeFileSync(path.join(dir, '.gitignore'), 'mentor_env/\n');
|
|
|
|
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
|
const providers = contracts.filter((c) => c.role === 'provider');
|
|
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
|
|
expect(providers.find((c) => c.contractId === 'http::GET::/api/leaked')).toBeUndefined();
|
|
expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false);
|
|
});
|
|
});
|
|
});
|