Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions gitnexus/src/core/ingestion/call-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1403,13 +1403,30 @@ export const processRoutesFromExtracted = async (
*/

/** Common method names on response/data objects that are NOT property accesses */
const RESPONSE_METHOD_BLOCKLIST = new Set([
'json', 'text', 'blob', 'arrayBuffer', 'formData', 'ok', 'status', 'headers',
'then', 'catch', 'finally', 'clone',
// Properties/methods to ignore when extracting consumer accessed keys from `data.X` patterns.
// Avoids false positives from Fetch API, Array, Object, Promise, and DOM access on variables
// that happen to share names with response variables (data, result, response, etc.).
const RESPONSE_ACCESS_BLOCKLIST = new Set([
// Fetch/Response API
'json', 'text', 'blob', 'arrayBuffer', 'formData', 'ok', 'status', 'headers', 'clone',
// Promise
'then', 'catch', 'finally',
// Array
'map', 'filter', 'forEach', 'reduce', 'find', 'some', 'every',
'length', 'toString', 'valueOf',
'push', 'pop', 'shift', 'unshift', 'splice', 'slice', 'concat', 'join',
'sort', 'reverse', 'includes', 'indexOf', 'keys', 'values', 'entries',
'sort', 'reverse', 'includes', 'indexOf',
// Object
'length', 'toString', 'valueOf', 'keys', 'values', 'entries',
// DOM methods — file-download patterns often reuse `data`/`response` variable names
'appendChild', 'removeChild', 'insertBefore', 'replaceChild', 'replaceChildren',
'createElement', 'getElementById', 'querySelector', 'querySelectorAll',
'setAttribute', 'getAttribute', 'removeAttribute', 'hasAttribute',
'addEventListener', 'removeEventListener', 'dispatchEvent',
'classList', 'className',
'parentNode', 'parentElement', 'childNodes', 'children',
'nextSibling', 'previousSibling', 'firstChild', 'lastChild',
'click', 'focus', 'blur', 'submit', 'reset',
'innerHTML', 'outerHTML', 'textContent', 'innerText',
]);

export const extractConsumerAccessedKeys = (content: string): string[] => {
Expand Down Expand Up @@ -1448,7 +1465,7 @@ export const extractConsumerAccessedKeys = (content: string): string[] => {
while ((match = propAccessPattern.exec(content)) !== null) {
const key = match[1];
// Skip common method calls that aren't property accesses
if (!RESPONSE_METHOD_BLOCKLIST.has(key)) {
if (!RESPONSE_ACCESS_BLOCKLIST.has(key)) {
keys.add(key);
}
}
Expand Down
26 changes: 25 additions & 1 deletion gitnexus/src/core/ingestion/route-extractors/response-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,31 @@ export function extractResponseShapes(content: string): { responseKeys?: string[
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') { inString = ch; continue; }
if (ch === '"' || ch === "'" || ch === '`') {
// Quoted string at depth 1 before ':' is a property key (e.g., { 'courses': data })
// The original parser only handled unquoted identifiers.
if (depth === 1 && keyStart === -1) {
const quote = ch;
const strStart = j + 1;
let strEnd = -1;
for (let s = strStart; s < content.length; s++) {
if (content[s] === '\\') { s++; continue; }
if (content[s] === quote) { strEnd = s; break; }
}
if (strEnd !== -1) {
// Scan forward for ':' without allocating a substring
let p = strEnd + 1;
while (p < content.length && (content[p] === ' ' || content[p] === '\t' || content[p] === '\n' || content[p] === '\r')) p++;
if (content[p] === ':') {
callKeys.push(content.slice(strStart, strEnd));
}
j = strEnd;
continue;
}
}
inString = ch;
continue;
}
if (ch === '{') { depth++; continue; }
if (ch === '}') { depth--; if (depth === 0) { closingBracePos = j; break; } continue; }
if (depth !== 1) continue;
Expand Down
17 changes: 14 additions & 3 deletions gitnexus/src/mcp/local/local-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1653,14 +1653,19 @@ export class LocalBackend {
r.reason AS fetchReason
`, params);

// Strip wrapping quotes from DB array elements — CSV COPY stores ['key'] which
// LadybugDB may return as "'key'" rather than "key"
const stripQuotes = (keys: string[] | null): string[] | null =>
keys ? keys.map(k => k.replace(/^['"]|['"]$/g, '')) : null;

const routeMap = new Map<string, { id: string; name: string; filePath: string; responseKeys: string[] | null; errorKeys: string[] | null; middleware: string[] | null; consumers: Array<{ name: string; filePath: string; accessedKeys?: string[]; fetchCount?: number }> }>();
for (const row of rows) {
const id = row.routeId ?? row[0];
const name = row.routeName ?? row[1];
const filePath = row.handlerFile ?? row[2];
const responseKeys: string[] | null = row.responseKeys ?? row[3] ?? null;
const errorKeys: string[] | null = row.errorKeys ?? row[4] ?? null;
const middleware: string[] | null = row.middleware ?? row[5] ?? null;
const responseKeys = stripQuotes(row.responseKeys ?? row[3] ?? null);
const errorKeys = stripQuotes(row.errorKeys ?? row[4] ?? null);
const middleware = stripQuotes(row.middleware ?? row[5] ?? null);
const consumerName = row.consumerName ?? row[6];
const consumerFile = row.consumerFile ?? row[7];
const fetchReason: string | null = row.fetchReason ?? row[8] ?? null;
Expand Down Expand Up @@ -1755,23 +1760,28 @@ export class LocalBackend {
const results = allRoutes
.filter(r => ((r.responseKeys && r.responseKeys.length > 0) || (r.errorKeys && r.errorKeys.length > 0)) && r.consumers.length > 0)
.map(r => {
// Keys already normalized by fetchRoutesWithConsumers (quotes stripped)
const responseKeys = r.responseKeys ?? [];
const errorKeys = r.errorKeys ?? [];
// Combined set: consumer accessing either success or error keys is valid
const allKnownKeys = new Set([...responseKeys, ...errorKeys]);

// Check each consumer's accessed keys against the route's response shape
const responseKeySet = new Set(responseKeys);
const consumers = r.consumers.map(c => {
if (!c.accessedKeys || c.accessedKeys.length === 0) {
return { name: c.name, filePath: c.filePath };
}
const mismatched = c.accessedKeys.filter(k => !allKnownKeys.has(k));
// Keys in allKnownKeys but not in responseKeys — error-path access (e.g., .error from errorKeys)
const errorPathKeys = c.accessedKeys.filter(k => allKnownKeys.has(k) && !responseKeySet.has(k));
const isMultiFetch = (c.fetchCount ?? 1) > 1;
return {
name: c.name,
filePath: c.filePath,
accessedKeys: c.accessedKeys,
...(mismatched.length > 0 ? { mismatched, mismatchConfidence: isMultiFetch ? 'low' as const : 'high' as const } : {}),
...(errorPathKeys.length > 0 ? { errorPathKeys } : {}),
...(isMultiFetch ? { attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.` } : {}),
};
});
Expand Down Expand Up @@ -1873,6 +1883,7 @@ export class LocalBackend {
}

const results = routes.map(r => {
// Keys already normalized by fetchRoutesWithConsumers (quotes stripped)
const responseKeys = r.responseKeys ?? [];
const errorKeys = r.errorKeys ?? [];
const allKnownKeys = new Set([...responseKeys, ...errorKeys]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { NextResponse } from 'next/server';

export async function POST() {
const archive = await buildExport();
return NextResponse.json({ url: archive.url });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';

export async function GET() {
const courses = await getCourses();
const articles = await getArticles();
return NextResponse.json({ 'courses': courses, 'articles': articles });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';

export async function GET() {
const users = await getUsers();
return NextResponse.json({ data: users, total: users.length });
}

export async function POST(req: Request) {
const body = await req.json();
if (!body.name) {
return NextResponse.json({ error: 'Name required', details: 'missing field' }, { status: 400 });
}
return NextResponse.json({ data: body, success: true });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function GdprExport() {
const data = fetch('/api/gdpr/export', { method: 'POST' }).then(r => r.json());
const link = document.createElement('a');
link.href = data.url;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function SearchBar() {
const data = fetch('/api/search').then(r => r.json());
console.log(data.courses);
console.log(data.articles);
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function UserList() {
const res = fetch('/api/users').then(r => r.json());
const items = res.data;
const err = res.error;
return null;
}
92 changes: 92 additions & 0 deletions gitnexus/test/integration/resolvers/shape-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES, getRelationships, getNodesByLabelFull,
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';

describe('shape_check integration', () => {
let result: PipelineResult;

beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'shape-check-integration'),
() => {},
);
}, 60000);

// --- Route & response shape extraction ---

it('creates Route nodes with responseKeys', () => {
const routes = getNodesByLabelFull(result, 'Route');
const searchRoute = routes.find(r => r.name === '/api/search');
expect(searchRoute).toBeDefined();
expect(searchRoute!.properties.responseKeys).toBeDefined();
expect(searchRoute!.properties.responseKeys.length).toBeGreaterThan(0);
});

it('extracts quoted property keys without wrapping quotes', () => {
const routes = getNodesByLabelFull(result, 'Route');
const searchRoute = routes.find(r => r.name === '/api/search');
expect(searchRoute).toBeDefined();
for (const key of searchRoute!.properties.responseKeys) {
expect(key).not.toMatch(/['"]/);
}
expect(searchRoute!.properties.responseKeys).toContain('courses');
expect(searchRoute!.properties.responseKeys).toContain('articles');
});

it('separates responseKeys from errorKeys by HTTP status', () => {
const routes = getNodesByLabelFull(result, 'Route');
const usersRoute = routes.find(r => r.name === '/api/users');
expect(usersRoute).toBeDefined();
expect(usersRoute!.properties.responseKeys).toEqual(
expect.arrayContaining(['data']),
);
expect(usersRoute!.properties.errorKeys).toEqual(
expect.arrayContaining(['error']),
);
});

// --- Consumer key extraction ---

it('creates FETCHES edges with accessedKeys in reason', () => {
const edges = getRelationships(result, 'FETCHES');
const searchFetch = edges.find(e =>
e.sourceFilePath.includes('SearchBar') && e.target === '/api/search',
);
expect(searchFetch).toBeDefined();
expect(searchFetch!.rel.reason).toContain('keys:');
const keysStr = searchFetch!.rel.reason!.match(/keys:([^|]+)/)?.[1] ?? '';
const keys = keysStr.split(',');
expect(keys).toContain('courses');
expect(keys).toContain('articles');
});

it('does not include DOM methods in consumer accessedKeys', () => {
const edges = getRelationships(result, 'FETCHES');
const gdprFetch = edges.find(e =>
e.sourceFilePath.includes('GdprExport') && e.target === '/api/gdpr/export',
);
expect(gdprFetch).toBeDefined();
const keysStr = gdprFetch!.rel.reason!.match(/keys:([^|]+)/)?.[1] ?? '';
const keys = keysStr.split(',');
expect(keys).not.toContain('appendChild');
expect(keys).not.toContain('removeChild');
expect(keys).not.toContain('createElement');
expect(keys).not.toContain('click');
expect(keys).toContain('url');
});

it('captures error-path key access from consumers', () => {
const edges = getRelationships(result, 'FETCHES');
const userFetch = edges.find(e =>
e.sourceFilePath.includes('UserList') && e.target === '/api/users',
);
expect(userFetch).toBeDefined();
const keysStr = userFetch!.rel.reason!.match(/keys:([^|]+)/)?.[1] ?? '';
const keys = keysStr.split(',');
expect(keys).toContain('data');
expect(keys).toContain('error');
});
});
Loading
Loading