mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-04 07:27:20 +00:00
fix(routes): link consumers to wrapper-composed URLs (#3697)
* fix(routes): resolve wrapper URL prefixes Carry proven URL composition through lexical bindings and wrapper calls so consumers match the runtime mount path. Refs #2858 * fix(review): apply review findings * fix(routes): address PR review feedback (#3697) * fix(routes): avoid native parent walks in URL facts --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
parent
62a6f67083
commit
f3fa67b144
8 changed files with 840 additions and 14 deletions
|
|
@ -574,8 +574,9 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
if (routeRegistry.size > 0 && wrapperCalls.length > 0) {
|
||||
const wrappers = buildFetchWrapperIndex(allFetchWrapperDefs ?? [], configuredWrappers);
|
||||
for (const call of wrapperCalls) {
|
||||
if (wrappers.isWrapperCall(call)) allFetchCalls.push(call);
|
||||
else unprovenCalls.push(call);
|
||||
const urls = wrappers.urlsForCall(call);
|
||||
if (urls.length === 0 && !wrappers.isWrapperCall(call)) unprovenCalls.push(call);
|
||||
for (const fetchURL of urls) allFetchCalls.push({ ...call, fetchURL });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import type Parser from 'tree-sitter';
|
||||
import type { ForwardedCallee } from './fetch-wrapper.js';
|
||||
|
||||
type Node = Parser.SyntaxNode;
|
||||
|
||||
/** Serializable string operations; no source code is evaluated. */
|
||||
export type FetchUrlExpression =
|
||||
| string
|
||||
| { kind: 'parameter' }
|
||||
| { kind: 'concat'; parts: FetchUrlExpression[] }
|
||||
| { kind: 'call'; callee: ForwardedCallee; argument: FetchUrlExpression }
|
||||
| {
|
||||
kind: 'leading-slash';
|
||||
value: FetchUrlExpression;
|
||||
consequence: FetchUrlExpression;
|
||||
alternative: FetchUrlExpression;
|
||||
};
|
||||
|
||||
export interface FetchUrlArgument {
|
||||
callee: ForwardedCallee;
|
||||
argument: FetchUrlExpression;
|
||||
fetch: boolean;
|
||||
}
|
||||
|
||||
/** Follow only stable lexical bindings and supported string syntax. */
|
||||
export function readFetchUrlExpression(
|
||||
expression: Node | null | undefined,
|
||||
parameter: string | undefined,
|
||||
binding: (node: Node) => string | undefined,
|
||||
valueOf: (binding: string) => Node | undefined,
|
||||
targetOf: (node: Node | null) => ForwardedCallee | undefined,
|
||||
mayBeBase: (node: Node) => boolean,
|
||||
): FetchUrlExpression | undefined {
|
||||
const active = new Set<string>();
|
||||
let remaining = 256;
|
||||
const read = (node: Node | null | undefined, depth = 0): FetchUrlExpression | undefined => {
|
||||
if (!node || depth > 32 || --remaining < 0) return undefined;
|
||||
const next = (child: Node | null | undefined) => read(child, depth + 1);
|
||||
if (
|
||||
[
|
||||
'parenthesized_expression',
|
||||
'as_expression',
|
||||
'satisfies_expression',
|
||||
'non_null_expression',
|
||||
].includes(node.type)
|
||||
)
|
||||
return next(node.namedChildren.find((child) => child.type !== 'comment'));
|
||||
if (node.type === 'identifier') {
|
||||
const id = binding(node);
|
||||
if (!id) return undefined;
|
||||
if (id === parameter) return { kind: 'parameter' };
|
||||
if (active.has(id)) return undefined;
|
||||
active.add(id);
|
||||
const result = next(valueOf(id));
|
||||
active.delete(id);
|
||||
return result;
|
||||
}
|
||||
// Escapes require runtime decoding; do not mistake their source spelling for a path.
|
||||
if (node.type === 'string')
|
||||
return node.text.includes('\\') ? undefined : node.text.slice(1, -1);
|
||||
if (node.type === 'binary_expression') {
|
||||
if (node.childForFieldName('operator')?.text !== '+') return undefined;
|
||||
const left = next(node.childForFieldName('left'));
|
||||
const right = next(node.childForFieldName('right'));
|
||||
return left === undefined || right === undefined
|
||||
? undefined
|
||||
: { kind: 'concat', parts: [left, right] };
|
||||
}
|
||||
if (node.type === 'template_string') {
|
||||
const parts: FetchUrlExpression[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'string_fragment') parts.push(child.text);
|
||||
else if (child.type === 'template_substitution') {
|
||||
const inner = child.namedChildren.find((item) => item.type !== 'comment');
|
||||
const value = next(inner);
|
||||
if (value !== undefined) parts.push(value);
|
||||
// Preserve the existing leading base-URL convention. An unknown
|
||||
// interpolation anywhere after the base cannot prove a static URL.
|
||||
else if (parts.length === 0 && inner && mayBeBase(inner)) parts.push('${base}');
|
||||
else return undefined;
|
||||
} else if (child.type !== 'comment') return undefined;
|
||||
}
|
||||
return { kind: 'concat', parts };
|
||||
}
|
||||
if (node.type === 'call_expression') {
|
||||
const callee = targetOf(node.childForFieldName('function'));
|
||||
const argument = next(
|
||||
node
|
||||
.childForFieldName('arguments')
|
||||
?.namedChildren.find((child) => child.type !== 'comment'),
|
||||
);
|
||||
return callee && argument !== undefined ? { kind: 'call', callee, argument } : undefined;
|
||||
}
|
||||
if (node.type === 'ternary_expression') {
|
||||
const condition = node.childForFieldName('condition');
|
||||
const callee = condition?.childForFieldName('function');
|
||||
const args = condition
|
||||
?.childForFieldName('arguments')
|
||||
?.namedChildren.filter((child) => child.type !== 'comment');
|
||||
if (
|
||||
condition?.type !== 'call_expression' ||
|
||||
callee?.type !== 'member_expression' ||
|
||||
callee.childForFieldName('property')?.text !== 'startsWith' ||
|
||||
args?.length !== 1 ||
|
||||
next(args[0]) !== '/'
|
||||
)
|
||||
return undefined;
|
||||
const value = next(callee.childForFieldName('object'));
|
||||
const consequence = next(node.childForFieldName('consequence'));
|
||||
const alternative = next(node.childForFieldName('alternative'));
|
||||
return value === undefined || consequence === undefined || alternative === undefined
|
||||
? undefined
|
||||
: { kind: 'leading-slash', value, consequence, alternative };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return read(expression);
|
||||
}
|
||||
|
||||
/** Substitute a caller's URL, resolving only a proven URL-building function. */
|
||||
export function evaluateFetchUrlExpression(
|
||||
expression: FetchUrlExpression,
|
||||
parameter: string,
|
||||
returned: (callee: ForwardedCallee, argument: string) => string | undefined,
|
||||
depth = 0,
|
||||
): string | undefined {
|
||||
if (depth > 32) return undefined;
|
||||
if (typeof expression === 'string') return expression;
|
||||
const evaluate = (value: FetchUrlExpression) =>
|
||||
evaluateFetchUrlExpression(value, parameter, returned, depth + 1);
|
||||
if (expression.kind === 'parameter') return parameter;
|
||||
if (expression.kind === 'call') {
|
||||
const argument = evaluate(expression.argument);
|
||||
return argument === undefined ? undefined : returned(expression.callee, argument);
|
||||
}
|
||||
if (expression.kind === 'leading-slash') {
|
||||
const value = evaluate(expression.value);
|
||||
return value === undefined || value.startsWith('${')
|
||||
? undefined
|
||||
: evaluate(value.startsWith('/') ? expression.consequence : expression.alternative);
|
||||
}
|
||||
let result = '';
|
||||
for (const part of expression.parts) {
|
||||
const value = evaluate(part);
|
||||
if (value === undefined || result.length + value.length > 8192) return undefined;
|
||||
result += value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
import type { FetchWrapperDef } from '../workers/parse-worker.js';
|
||||
import type { CalleeTarget, FetchWrapperCall, FetchWrapperReexport } from './fetch-wrapper.js';
|
||||
import type {
|
||||
CalleeTarget,
|
||||
FetchWrapperCall,
|
||||
FetchWrapperReexport,
|
||||
ForwardedCallee,
|
||||
} from './fetch-wrapper.js';
|
||||
import { evaluateFetchUrlExpression, type FetchUrlExpression } from './fetch-url-expression.js';
|
||||
|
||||
interface NameGroup {
|
||||
remainingCarriers: number;
|
||||
|
|
@ -19,6 +25,7 @@ export function buildFetchWrapperIndex(
|
|||
configuredNames: readonly string[] = [],
|
||||
): {
|
||||
isWrapperCall(call: Pick<FetchWrapperCall, 'filePath' | 'functionName' | 'target'>): boolean;
|
||||
urlsForCall(call: FetchWrapperCall): string[];
|
||||
} {
|
||||
const configured = new Set(configuredNames);
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -93,7 +100,10 @@ export function buildFetchWrapperIndex(
|
|||
for (let index = 0; index < defs.length; index++) {
|
||||
const definition = defs[index];
|
||||
if (definition.forwardsUrl) activate(index);
|
||||
for (const forwarded of definition.forwardsTo) {
|
||||
for (const forwarded of [
|
||||
...definition.forwardsTo,
|
||||
...(definition.urlArguments ?? []).map((flow) => flow.callee),
|
||||
]) {
|
||||
if (configured.has(forwarded.callee)) {
|
||||
activate(index);
|
||||
} else if (forwarded.target.kind === 'member') {
|
||||
|
|
@ -117,12 +127,148 @@ export function buildFetchWrapperIndex(
|
|||
}
|
||||
}
|
||||
|
||||
const isWrapperCall = ({
|
||||
filePath,
|
||||
functionName,
|
||||
target,
|
||||
}: Pick<FetchWrapperCall, 'filePath' | 'functionName' | 'target'>) => {
|
||||
if (configured.has(functionName)) return true;
|
||||
if (target.kind === 'member') return names.get(functionName)?.hasWrapper ?? false;
|
||||
const index = targetDefinition(filePath, functionName, target);
|
||||
return index !== undefined && index !== null && (defs[index].seed || carriers.has(index));
|
||||
};
|
||||
// Collapse identity forwarding iteratively so long wrapper chains retain
|
||||
// the carrier pass's linear behavior without consuming transformation depth.
|
||||
const identityRoots = new Map<number, number>();
|
||||
const identityRoot = (start: number): number => {
|
||||
const path: number[] = [];
|
||||
const visiting = new Set<number>();
|
||||
let index = start;
|
||||
while (!identityRoots.has(index)) {
|
||||
if (visiting.has(index)) return start;
|
||||
visiting.add(index);
|
||||
path.push(index);
|
||||
const flows = defs[index].urlArguments;
|
||||
const flow = flows?.length === 1 ? flows[0] : undefined;
|
||||
if (
|
||||
!flow ||
|
||||
flow.fetch ||
|
||||
typeof flow.argument === 'string' ||
|
||||
flow.argument.kind !== 'parameter' ||
|
||||
(flow.callee.target.kind !== 'binding' && flow.callee.target.kind !== 'resolved-import')
|
||||
)
|
||||
break;
|
||||
const target = targetDefinition(defs[index].filePath, flow.callee.callee, flow.callee.target);
|
||||
if (
|
||||
target === undefined ||
|
||||
target === null ||
|
||||
(!defs[target].seed && !carriers.has(target)) ||
|
||||
configured.has(defs[index].functionName)
|
||||
)
|
||||
break;
|
||||
index = target;
|
||||
}
|
||||
const root = identityRoots.get(index) ?? index;
|
||||
for (const entry of path) identityRoots.set(entry, root);
|
||||
return root;
|
||||
};
|
||||
const urlsForDefinition = (
|
||||
index: number,
|
||||
input: string,
|
||||
active: Set<number>,
|
||||
budget: { remaining: number },
|
||||
): string[] => {
|
||||
index = identityRoot(index);
|
||||
if (active.has(index) || active.size >= 32 || --budget.remaining < 0) return [];
|
||||
const definition = defs[index];
|
||||
// Existing/configured wrappers without string-operation evidence retain
|
||||
// their literal path. New facts with an empty list explicitly prove none.
|
||||
if (definition.urlArguments === undefined) return [input];
|
||||
active.add(index);
|
||||
const evaluate = (expression: FetchUrlExpression): string | undefined =>
|
||||
evaluateFetchUrlExpression(expression, input, (callee, argument) =>
|
||||
returnedUrl(index, callee, argument, active, budget),
|
||||
);
|
||||
const urls = new Set<string>();
|
||||
for (const flow of definition.urlArguments) {
|
||||
if (--budget.remaining < 0) break;
|
||||
const argument = evaluate(flow.argument);
|
||||
if (argument === undefined) continue;
|
||||
if (flow.fetch) urls.add(argument);
|
||||
else if (flow.callee.target.kind === 'member') {
|
||||
const group = names.get(flow.callee.callee);
|
||||
if (
|
||||
configured.has(flow.callee.callee) ||
|
||||
(group?.hasWrapper && group.remainingCarriers === 0)
|
||||
)
|
||||
urls.add(argument);
|
||||
} else {
|
||||
const target = targetDefinition(
|
||||
definition.filePath,
|
||||
flow.callee.callee,
|
||||
flow.callee.target,
|
||||
);
|
||||
if (
|
||||
target !== undefined &&
|
||||
target !== null &&
|
||||
(defs[target].seed || carriers.has(target))
|
||||
) {
|
||||
if (
|
||||
flow.callee.target.kind === 'binding' ||
|
||||
flow.callee.target.kind === 'resolved-import'
|
||||
)
|
||||
for (const url of urlsForDefinition(target, argument, active, budget)) urls.add(url);
|
||||
else urls.add(argument); // Legacy name-only classification cannot prove a transformation.
|
||||
} else if (configured.has(flow.callee.callee)) urls.add(argument);
|
||||
}
|
||||
}
|
||||
active.delete(index);
|
||||
return urls.size === 0 && configured.has(definition.functionName) ? [input] : [...urls];
|
||||
};
|
||||
const returnedUrl = (
|
||||
from: number,
|
||||
callee: ForwardedCallee,
|
||||
input: string,
|
||||
active: Set<number>,
|
||||
budget: { remaining: number },
|
||||
): string | undefined => {
|
||||
if (callee.target.kind !== 'binding' && callee.target.kind !== 'resolved-import')
|
||||
return undefined;
|
||||
const target = targetDefinition(defs[from].filePath, callee.callee, callee.target);
|
||||
if (
|
||||
target === undefined ||
|
||||
target === null ||
|
||||
active.has(target) ||
|
||||
active.size >= 32 ||
|
||||
--budget.remaining < 0
|
||||
)
|
||||
return undefined;
|
||||
const expression = defs[target].urlReturn;
|
||||
if (expression === undefined) return undefined;
|
||||
active.add(target);
|
||||
const result = evaluateFetchUrlExpression(expression, input, (nested, value) =>
|
||||
returnedUrl(target, nested, value, active, budget),
|
||||
);
|
||||
active.delete(target);
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
isWrapperCall: ({ filePath, functionName, target }) => {
|
||||
if (configured.has(functionName)) return true;
|
||||
if (target.kind === 'member') return names.get(functionName)?.hasWrapper ?? false;
|
||||
const index = targetDefinition(filePath, functionName, target);
|
||||
return index !== undefined && index !== null && (defs[index].seed || carriers.has(index));
|
||||
isWrapperCall,
|
||||
urlsForCall: (call) => {
|
||||
if (!isWrapperCall(call)) return [];
|
||||
const index = targetDefinition(call.filePath, call.functionName, call.target);
|
||||
// Configuration explicitly establishes an opaque HTTP sink, including
|
||||
// custom clients whose internals are outside auto-detection's evidence.
|
||||
if (
|
||||
configured.has(call.functionName) &&
|
||||
(index === undefined || index === null || (!defs[index].seed && !carriers.has(index)))
|
||||
)
|
||||
return [call.fetchURL];
|
||||
return index === undefined ||
|
||||
index === null ||
|
||||
(call.target.kind !== 'binding' && call.target.kind !== 'resolved-import')
|
||||
? [call.fetchURL]
|
||||
: urlsForDefinition(index, call.fetchURL, new Set(), { remaining: 256 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -273,17 +419,59 @@ export function resolveFetchWrapperTargets(
|
|||
}
|
||||
);
|
||||
};
|
||||
const resolveExpression = (
|
||||
filePath: string,
|
||||
expression: FetchUrlExpression,
|
||||
): FetchUrlExpression => {
|
||||
if (typeof expression === 'string' || expression.kind === 'parameter') return expression;
|
||||
if (expression.kind === 'call')
|
||||
return {
|
||||
...expression,
|
||||
callee: { ...expression.callee, target: resolve(filePath, expression.callee.target) },
|
||||
argument: resolveExpression(filePath, expression.argument),
|
||||
};
|
||||
if (expression.kind === 'concat')
|
||||
return {
|
||||
...expression,
|
||||
parts: expression.parts.map((part) => resolveExpression(filePath, part)),
|
||||
};
|
||||
return {
|
||||
...expression,
|
||||
value: resolveExpression(filePath, expression.value),
|
||||
consequence: resolveExpression(filePath, expression.consequence),
|
||||
alternative: resolveExpression(filePath, expression.alternative),
|
||||
};
|
||||
};
|
||||
// Aggregates share facts with the parse cache. Keep its raw specifiers intact
|
||||
// so a warm parse resolves imports against the current project configuration.
|
||||
return {
|
||||
definitions: definitions.map((definition) =>
|
||||
definition.forwardsTo.some((forwarded) => forwarded.target.kind === 'import')
|
||||
definition.forwardsTo.some((forwarded) => forwarded.target.kind === 'import') ||
|
||||
definition.urlArguments !== undefined ||
|
||||
definition.urlReturn !== undefined
|
||||
? {
|
||||
...definition,
|
||||
forwardsTo: definition.forwardsTo.map((forwarded) => ({
|
||||
...forwarded,
|
||||
target: resolve(definition.filePath, forwarded.target),
|
||||
})),
|
||||
...(definition.urlArguments !== undefined
|
||||
? {
|
||||
urlArguments: definition.urlArguments.map((flow) => ({
|
||||
...flow,
|
||||
callee: {
|
||||
...flow.callee,
|
||||
target: resolve(definition.filePath, flow.callee.target),
|
||||
},
|
||||
argument: resolveExpression(definition.filePath, flow.argument),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(definition.urlReturn !== undefined
|
||||
? {
|
||||
urlReturn: resolveExpression(definition.filePath, definition.urlReturn),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: definition,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ import type Parser from 'tree-sitter';
|
|||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import type { ExtractedFetchCall } from '../workers/parse-worker.js';
|
||||
import { stripLeadingTemplatePrefix } from './route-path.js';
|
||||
import {
|
||||
readFetchUrlExpression,
|
||||
type FetchUrlArgument,
|
||||
type FetchUrlExpression,
|
||||
} from './fetch-url-expression.js';
|
||||
import {
|
||||
bindingKey,
|
||||
createLexicalBindings,
|
||||
|
|
@ -42,6 +47,10 @@ export interface FetchWrapperFacts {
|
|||
seed: boolean;
|
||||
forwardsUrl: boolean;
|
||||
forwardsTo: ForwardedCallee[];
|
||||
/** Proven direct URL operations, retained through worker/cache serialization. */
|
||||
urlArguments?: FetchUrlArgument[];
|
||||
/** A single returned string expression, for helpers such as getApiUrl(path). */
|
||||
urlReturn?: FetchUrlExpression;
|
||||
}
|
||||
/** A module export relevant to barrel resolution. */
|
||||
export interface FetchWrapperReexport {
|
||||
|
|
@ -115,6 +124,7 @@ const INSPECTED_TYPES: ReadonlySet<string> = new Set([
|
|||
'export_statement',
|
||||
'export_specifier',
|
||||
'call_expression',
|
||||
'return_statement',
|
||||
]);
|
||||
/** Fetch implementations imported from packages, by module specifier and export name.
|
||||
* A namespace or `require` value is recorded under the specifier itself. */
|
||||
|
|
@ -130,6 +140,7 @@ interface Region {
|
|||
node: Node;
|
||||
edges: Set<Region>;
|
||||
calls: Node[];
|
||||
returns: Node[];
|
||||
}
|
||||
/** A visited ancestor of the cursor. */
|
||||
interface Frame {
|
||||
|
|
@ -245,6 +256,7 @@ export function createFetchWrapperContext(
|
|||
const aliases = new Set<string>();
|
||||
const invalid = new Set<string>();
|
||||
const initialized = new Map<string, number>();
|
||||
const bindingValues = new Map<string, Node>();
|
||||
const parameters = new Set<string>();
|
||||
const exports = new Map<string, Set<string>>();
|
||||
const boundNames = new Set<string>();
|
||||
|
|
@ -262,6 +274,7 @@ export function createFetchWrapperContext(
|
|||
// Native parent lookup descends from the tree root, so the walk records the
|
||||
// ancestor facts that later queries need.
|
||||
const storedNames = new Map<number, Node | null>();
|
||||
const directReturns = new Set<number>();
|
||||
const defaultExports = new Set<number>();
|
||||
const declarators = new Map<number, Node>();
|
||||
const region = (node: Node): Region => {
|
||||
|
|
@ -269,6 +282,7 @@ export function createFetchWrapperContext(
|
|||
node,
|
||||
edges: new Set<Region>(),
|
||||
calls: [] as Node[],
|
||||
returns: [] as Node[],
|
||||
};
|
||||
regions.push(result);
|
||||
return result;
|
||||
|
|
@ -303,6 +317,7 @@ export function createFetchWrapperContext(
|
|||
if (!value) continue;
|
||||
initialized.set(id, (initialized.get(id) ?? 0) + 1);
|
||||
if (!parameter && parameters.has(id)) invalid.add(id);
|
||||
if (!parameter && pattern?.type === 'identifier') bindingValues.set(id, value);
|
||||
if (pattern?.type === 'identifier' && namesFetch(value)) aliases.add(id);
|
||||
}
|
||||
};
|
||||
|
|
@ -555,6 +570,10 @@ export function createFetchWrapperContext(
|
|||
) {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name) exported(name, node.childForFieldName('alias')?.text ?? name.text);
|
||||
} else if (type === 'return_statement') {
|
||||
active.returns.push(node);
|
||||
if (parent.type === 'statement_block' && frames[frames.length - 2]?.children === 'callable')
|
||||
directReturns.add(node.id);
|
||||
} else if (type === 'call_expression' && !node.hasError) {
|
||||
active.calls.push(node);
|
||||
allCalls.push(node);
|
||||
|
|
@ -775,6 +794,77 @@ export function createFetchWrapperContext(
|
|||
// A rebound binding no longer names this function, so it proves no wrapper
|
||||
// across files either; its names are kept so they still collide.
|
||||
const rebound = !!id && invalid.has(id);
|
||||
const readUrl = (node: Node | null | undefined) =>
|
||||
readFetchUrlExpression(
|
||||
node,
|
||||
url,
|
||||
(value) => {
|
||||
const key = stableBinding(value);
|
||||
return key && !invalid.has(key) ? key : undefined;
|
||||
},
|
||||
(key) => bindingValues.get(key),
|
||||
(callee) => {
|
||||
const name = calleeName(callee);
|
||||
return name ? { callee: name, target: targetOf(callee) } : undefined;
|
||||
},
|
||||
(value) => {
|
||||
if (value.type !== 'identifier') return false;
|
||||
const key = stableBinding(value);
|
||||
return bindings.isGlobal(value, value.text) || (!!key && !invalid.has(key));
|
||||
},
|
||||
);
|
||||
const dependsOnParameter = (expression: FetchUrlExpression): boolean => {
|
||||
if (typeof expression === 'string') return false;
|
||||
switch (expression.kind) {
|
||||
case 'parameter':
|
||||
return true;
|
||||
case 'concat':
|
||||
return expression.parts.some(dependsOnParameter);
|
||||
case 'call':
|
||||
return dependsOnParameter(expression.argument);
|
||||
case 'leading-slash':
|
||||
return (
|
||||
dependsOnParameter(expression.value) ||
|
||||
dependsOnParameter(expression.consequence) ||
|
||||
dependsOnParameter(expression.alternative)
|
||||
);
|
||||
}
|
||||
};
|
||||
const urlArguments: FetchUrlArgument[] = [];
|
||||
const recordUrl = (
|
||||
callee: Node | null,
|
||||
argumentNode: Node | null | undefined,
|
||||
fetch: boolean,
|
||||
) => {
|
||||
const name = calleeName(callee);
|
||||
if (!name) return;
|
||||
const argument = readUrl(argumentNode);
|
||||
if (argument !== undefined && dependsOnParameter(argument))
|
||||
urlArguments.push({ callee: { callee: name, target: targetOf(callee) }, argument, fetch });
|
||||
};
|
||||
for (const call of root.calls) {
|
||||
const callee = unwrap(call.childForFieldName('function'));
|
||||
const name = calleeName(callee);
|
||||
if (!name) continue;
|
||||
const alias = callee?.type === 'identifier' ? stableBinding(callee) : undefined;
|
||||
const fetch = platformFetch(callee) || !!(alias && aliases.has(alias));
|
||||
recordUrl(callee, firstNamed(call.childForFieldName('arguments')), fetch);
|
||||
}
|
||||
for (const call of url ? (parameterCalls.get(url) ?? []) : [])
|
||||
if (call.region !== root && reaches(call.region))
|
||||
recordUrl(call.callee, call.argument, call.seed);
|
||||
const body = fn.childForFieldName('body');
|
||||
const explicitReturn = root.returns.length === 1 ? root.returns[0] : undefined;
|
||||
const returnNode =
|
||||
body?.type !== 'statement_block'
|
||||
? body
|
||||
: explicitReturn && directReturns.has(explicitReturn.id)
|
||||
? firstNamed(explicitReturn)
|
||||
: undefined;
|
||||
const returnsString =
|
||||
!fn.type.startsWith('generator_') &&
|
||||
!fn.children.some((child) => child.type === 'async' || child.type === '*');
|
||||
const urlReturn = !rebound && url && returnsString ? readUrl(returnNode) : undefined;
|
||||
const result: FetchWrapperFacts = {
|
||||
definitionId: `${filePath}:${fn.startPosition.row + lineOffset + 1}:${fn.startPosition.column}:${fn.endPosition.row + lineOffset + 1}:${fn.endPosition.column}`,
|
||||
...(id ? { bindingId: id } : {}),
|
||||
|
|
@ -792,6 +882,8 @@ export function createFetchWrapperContext(
|
|||
seed: seed && !rebound,
|
||||
forwardsUrl: forwardsUrl && !rebound,
|
||||
forwardsTo: rebound ? [] : [...forwardsTo.values()],
|
||||
...(!rebound && (seed || urlArguments.length > 0) ? { urlArguments } : {}),
|
||||
...(urlReturn !== undefined ? { urlReturn } : {}),
|
||||
};
|
||||
factsCache.set(key, result);
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -3214,11 +3214,14 @@ const processFileGroup = (
|
|||
parsedFile,
|
||||
lineOffset,
|
||||
);
|
||||
// Keep possible wrappers and the functions other files can name; a
|
||||
// nested non-wrapper can neither be imported nor carry a URL.
|
||||
if (
|
||||
facts &&
|
||||
(facts.seed || facts.forwardsUrl || facts.forwardsTo.length > 0 || facts.moduleScope)
|
||||
(facts.seed ||
|
||||
facts.forwardsUrl ||
|
||||
facts.forwardsTo.length > 0 ||
|
||||
facts.moduleScope ||
|
||||
facts.urlReturn !== undefined ||
|
||||
(facts.urlArguments?.length ?? 0) > 0)
|
||||
)
|
||||
wrapperDefs.push({ filePath: file.path, functionName: nodeName, ...facts });
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,24 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { runtimeParseIdentity } from '../../helpers/runtime-parse-identity.js';
|
||||
import { loadParseCache, saveParseCache } from '../../../src/storage/parse-cache.js';
|
||||
import {
|
||||
getDurableParsedFileDir,
|
||||
pruneAndSaveDurableParsedFileStore,
|
||||
} from '../../../src/storage/parsedfile-store.js';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
runPipelineFromRepo,
|
||||
writeFixtureRepo,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
const parseIdentity = await runtimeParseIdentity();
|
||||
|
||||
describe('Fetch wrapper consumer FETCHES edge extraction', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
|
|
@ -43,6 +54,122 @@ describe('Fetch wrapper consumer FETCHES edge extraction', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Mount-prefixed wrapper consumers (#2858)', () => {
|
||||
let root: string;
|
||||
let cacheDir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'consumer-mount-prefix-'));
|
||||
writeFixtureRepo(root, {
|
||||
'backend/main.py': `from fastapi import FastAPI
|
||||
app = FastAPI()
|
||||
@app.get('/api/models')
|
||||
def models(): return []
|
||||
@app.get('/api/agents/{id}')
|
||||
def agent(id: str): return {}
|
||||
@app.get('/models')
|
||||
def other_models(): return []
|
||||
@app.get('/unsupported')
|
||||
def unsupported(): return []
|
||||
`,
|
||||
'web/config.ts': `declare const VITE_API_BASE_URL: string;
|
||||
export function getApiUrl(path: string) {
|
||||
const base = VITE_API_BASE_URL || '';
|
||||
const prefix = '/api';
|
||||
const normalized = path.startsWith('/') ? path : '/' + path;
|
||||
return \`\${base}\${prefix}\${normalized}\`;
|
||||
}`,
|
||||
'web/request.ts': `import { getApiUrl } from './config';
|
||||
export function request(path: string) {
|
||||
const url = getApiUrl(path);
|
||||
return fetch(url);
|
||||
}
|
||||
export function direct(path: string) { return fetch('/api' + path); }
|
||||
export function versioned(path: string) { return fetch(\`/api\${path}\`); }
|
||||
export function forward(path: string) { return direct(path); }
|
||||
`,
|
||||
'web/clients/models.ts': `import { request } from '../request';
|
||||
export function models() { return request('/models'); }`,
|
||||
'web/clients/direct.ts': `import { direct } from '../request';
|
||||
export function models() { return direct('/models'); }`,
|
||||
'web/clients/agents.ts': `import { versioned } from '../request';
|
||||
export function agent(id: string) { return versioned(\`/agents/\${id}\`); }`,
|
||||
'web/clients/nested.ts': `import { forward } from '../request';
|
||||
export function models() {
|
||||
function request(path: string) { forward(path + ''); return 1; }
|
||||
return request('/models');
|
||||
}`,
|
||||
'web/clients/forward.ts': `import { forward } from '../request';
|
||||
export function models() { return forward('/models'); }`,
|
||||
'web/unsupported.ts': `function send(path: string) { return fetch(path.toUpperCase()); }
|
||||
send('/unsupported');`,
|
||||
'web/decoy.ts': `function request(channel: string) { return channel; }
|
||||
request('/models');
|
||||
const cache = new Map(); cache.get('/models');`,
|
||||
});
|
||||
cacheDir = await mkdtemp(path.join(tmpdir(), 'consumer-mount-cache-'));
|
||||
const parseCache = await loadParseCache(cacheDir, parseIdentity, cacheDir);
|
||||
result = await runPipelineFromRepo(root, () => {}, { parseCache });
|
||||
const savedKeys = await saveParseCache(cacheDir, parseCache);
|
||||
await pruneAndSaveDurableParsedFileStore(
|
||||
getDurableParsedFileDir(cacheDir),
|
||||
parseIdentity.base,
|
||||
new Set(savedKeys),
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
if (cacheDir) await rm(cacheDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('links the runtime URL through literals, forwarding, and an imported URL builder', () => {
|
||||
expect(
|
||||
getRelationships(result, 'FETCHES')
|
||||
.filter((edge) => edge.sourceFilePath.startsWith('web/clients/'))
|
||||
.map((edge) => `${edge.sourceFilePath} -> ${edge.target}`)
|
||||
.sort(),
|
||||
).toEqual([
|
||||
'web/clients/agents.ts -> /api/agents/{id}',
|
||||
'web/clients/direct.ts -> /api/models',
|
||||
'web/clients/forward.ts -> /api/models',
|
||||
'web/clients/models.ts -> /api/models',
|
||||
'web/clients/nested.ts -> /api/models',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not link the caller literal to an unrelated unprefixed route', () => {
|
||||
expect(getRelationships(result, 'FETCHES').filter((edge) => edge.target === '/models')).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('does not count an unknown transformed destination as the raw route consumer', () => {
|
||||
const route = [...result.graph.iterNodes()].find(
|
||||
(node) => node.label === 'Route' && node.properties.name === '/unsupported',
|
||||
);
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.properties.unverifiedConsumers).toBeUndefined();
|
||||
expect(
|
||||
getRelationships(result, 'FETCHES').filter((edge) => edge.target === '/unsupported'),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves composed consumers when replaying a persisted parse cache', async () => {
|
||||
const warm = await runPipelineFromRepo(root, () => {}, {
|
||||
parseCache: await loadParseCache(cacheDir, parseIdentity, cacheDir),
|
||||
});
|
||||
expect(warm.reparsedFileCount).toBe(0);
|
||||
expect(warm.parseCacheHitFileCount).toBeGreaterThan(0);
|
||||
const consumers = (value: PipelineResult) =>
|
||||
getRelationships(value, 'FETCHES')
|
||||
.map((edge) => `${edge.sourceFilePath} -> ${edge.target}`)
|
||||
.sort();
|
||||
expect(consumers(warm)).toEqual(consumers(result));
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
// GitNexus's own web client (#3488): `fetchWithTimeout(`${_backendUrl}/api/repos`)`
|
||||
// reaches fetch through `resilientFetch` and a local `fetchImpl` alias, and the
|
||||
// URL starts with a base-URL binding.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import Parser from 'tree-sitter';
|
|||
import JavaScript from 'tree-sitter-javascript';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import { createFetchWrapperContext } from '../../src/core/ingestion/route-extractors/fetch-wrapper.js';
|
||||
import { buildFetchWrapperIndex } from '../../src/core/ingestion/route-extractors/fetch-wrapper-index.js';
|
||||
import { normalizeFetchURL } from '../../src/core/ingestion/route-extractors/nextjs.js';
|
||||
|
||||
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
|
||||
import {
|
||||
|
|
@ -43,6 +45,122 @@ for (const [name, grammar, provider] of grammars) {
|
|||
describe(`${name} fetch wrapper calls`, () => {
|
||||
const { context, extract } = harness(grammar, provider);
|
||||
|
||||
it.each([
|
||||
{
|
||||
shape: 'a parameter-independent refresh alongside a URL fetch',
|
||||
source: `function request(path) { fetch('/api/refresh'); return fetch('/api' + path); }`,
|
||||
expected: ['/api/models'],
|
||||
},
|
||||
{
|
||||
shape: 'a literal-only call that must not activate a URL carrier',
|
||||
source: `function send(path) { return fetch(path); }
|
||||
function request(event) { return send('/api/telemetry'); }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'an unknown-leading caller URL through slash normalization',
|
||||
source: `function build(path) { return '/api' + (path.startsWith('/') ? path : '/' + path); }
|
||||
function request(path) { return fetch(build(path)); }`,
|
||||
input: '${BASE}/models',
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'a conditional-only URL-builder return with fallthrough',
|
||||
source: `function build(path) { if (runtime()) return '/api' + path; }
|
||||
function request(path) { return fetch(build(path)); }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'a URL builder with an early bare return',
|
||||
source: `function build(path) { if (runtime()) return; return '/api' + path; }
|
||||
function request(path) { return fetch(build(path)); }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'literal concatenation',
|
||||
source: `function request(path) { return fetch('/api' + path); }`,
|
||||
expected: ['/api/models'],
|
||||
},
|
||||
{
|
||||
shape: 'a stable local prefix and URL alias',
|
||||
source: `function request(path) { const prefix = '/api'; const url = prefix + path; return fetch(url); }`,
|
||||
expected: ['/api/models'],
|
||||
},
|
||||
{
|
||||
shape: 'a returned URL through a helper chain',
|
||||
source: `function prefix(path) { return '/api' + path; }
|
||||
function build(path) { return prefix(path); }
|
||||
function request(path) { const url = build(path); return fetch(url); }`,
|
||||
expected: ['/api/models'],
|
||||
},
|
||||
{
|
||||
shape: 'a transformed argument forwarded to another wrapper',
|
||||
source: `function send(path) { return fetch(path); }
|
||||
function request(path) { return send('/api' + path); }`,
|
||||
expected: ['/api/models'],
|
||||
},
|
||||
{
|
||||
shape: 'a mount prefix inside a reachable retry callback',
|
||||
source: `function request(path) { return retry(() => fetch('/api' + path)); }`,
|
||||
expected: ['/api/models'],
|
||||
},
|
||||
{
|
||||
shape: 'an unused nested fetch helper',
|
||||
source: `function request(path) { const unused = () => fetch('/api' + path); return path; }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'startsWith with an explicit offset',
|
||||
source: `function build(path) { const normalized = path.startsWith('/', 1) ? path : '/' + path; return '/api' + normalized; }
|
||||
function request(path) { return fetch(build(path)); }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'a rebound prefix',
|
||||
source: `function request(path) { let prefix = '/api'; prefix = runtime(); return fetch(prefix + path); }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'a rebound URL parameter',
|
||||
source: `function request(path) { path = runtime(); return fetch('/api' + path); }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'a URL builder with different branch returns',
|
||||
source: `function build(path) { if (runtime()) return '/private' + path; return '/api' + path; }
|
||||
function request(path) { return fetch(build(path)); }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'a recursive URL builder',
|
||||
source: `function build(path) { return build(path); }
|
||||
function request(path) { return fetch(build(path)); }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'an async URL builder returning a Promise',
|
||||
source: `async function build(path) { return '/api' + path; }
|
||||
function request(path) { return fetch(build(path)); }`,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
shape: 'a generator URL builder returning an iterator',
|
||||
source: `function* build(path) { return '/api' + path; }
|
||||
function request(path) { return fetch(build(path)); }`,
|
||||
expected: [],
|
||||
},
|
||||
])('resolves the actual URL for $shape (#2858)', ({ source, expected, input = '/models' }) => {
|
||||
const { root, context: wrapper } = context(`${source}\nrequest('${input}');`);
|
||||
const definitions = root.namedChildren.flatMap((node) => {
|
||||
const facts = wrapper.facts(node);
|
||||
const name = node.childForFieldName('name')?.text;
|
||||
return facts && name ? [{ ...facts, filePath: 'consumer.ts', functionName: name }] : [];
|
||||
});
|
||||
const index = buildFetchWrapperIndex(definitions);
|
||||
const call = wrapper.calls().find((candidate) => candidate.functionName === 'request')!;
|
||||
expect(index.urlsForCall(call).map(normalizeFetchURL)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('preserves exact callee names and source lines, including comments and optional calls', () => {
|
||||
expect(
|
||||
extract(
|
||||
|
|
|
|||
|
|
@ -30,6 +30,26 @@ function call(target: CalleeTarget, functionName = 'request'): FetchWrapperCall
|
|||
}
|
||||
|
||||
describe('fetch wrapper target classification', () => {
|
||||
it('preserves configured opaque clients when their bodies have no proven HTTP sink', () => {
|
||||
const index = buildFetchWrapperIndex(
|
||||
[
|
||||
definition('custom', {
|
||||
urlArguments: [
|
||||
{
|
||||
callee: { callee: 'axios.get', target: { kind: 'member' } },
|
||||
argument: { kind: 'parameter' },
|
||||
fetch: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
['request'],
|
||||
);
|
||||
expect(index.urlsForCall(call({ kind: 'binding', bindingId: 'custom' }))).toEqual([
|
||||
'/api/items',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the local binding rather than a same-named wrapper elsewhere', () => {
|
||||
const index = buildFetchWrapperIndex([
|
||||
definition('wrapper', { seed: true }),
|
||||
|
|
@ -514,3 +534,131 @@ describe('fetch wrapper barrel resolution depth', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('composed URL compatibility (#2858)', () => {
|
||||
const parameter = { kind: 'parameter' } as const;
|
||||
const sink = { callee: 'fetch', target: { kind: 'unresolved' } } as const;
|
||||
const ref = (bindingId: string) => ({
|
||||
callee: bindingId,
|
||||
target: { kind: 'binding', bindingId } as const,
|
||||
});
|
||||
const prefixed = definition('request', {
|
||||
seed: true,
|
||||
forwardsUrl: true,
|
||||
urlArguments: [
|
||||
{ callee: sink, fetch: true, argument: { kind: 'concat', parts: ['/api', parameter] } },
|
||||
],
|
||||
});
|
||||
|
||||
it('resolves a configured wrapper prefix through a forwarder', () => {
|
||||
const index = buildFetchWrapperIndex(
|
||||
[
|
||||
prefixed,
|
||||
definition('outer', {
|
||||
functionName: 'outer',
|
||||
urlArguments: [{ callee: ref('request'), argument: parameter, fetch: false }],
|
||||
}),
|
||||
],
|
||||
['request'],
|
||||
);
|
||||
expect(
|
||||
index.urlsForCall({
|
||||
...call({ kind: 'binding', bindingId: 'outer' }, 'outer'),
|
||||
fetchURL: '/items',
|
||||
}),
|
||||
).toEqual(['/api/items']);
|
||||
});
|
||||
|
||||
it('keeps explicitly configured seed wrappers opaque when their URL is unreadable', () => {
|
||||
const index = buildFetchWrapperIndex(
|
||||
[
|
||||
definition('request', { seed: true, urlArguments: [] }),
|
||||
definition('outer', {
|
||||
functionName: 'outer',
|
||||
urlArguments: [{ callee: ref('request'), argument: parameter, fetch: false }],
|
||||
}),
|
||||
],
|
||||
['request'],
|
||||
);
|
||||
expect(index.urlsForCall(call({ kind: 'binding', bindingId: 'request' }))).toEqual([
|
||||
'/api/items',
|
||||
]);
|
||||
expect(index.urlsForCall(call({ kind: 'binding', bindingId: 'outer' }, 'outer'))).toEqual([
|
||||
'/api/items',
|
||||
]);
|
||||
});
|
||||
|
||||
it('retains member forwarding with serialized URL facts', () => {
|
||||
const index = buildFetchWrapperIndex([
|
||||
definition('send', { functionName: 'api.send', seed: true, forwardsUrl: true }),
|
||||
definition('outer', {
|
||||
functionName: 'outer',
|
||||
urlArguments: [
|
||||
{
|
||||
callee: { callee: 'api.send', target: { kind: 'member' } },
|
||||
argument: parameter,
|
||||
fetch: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(index.urlsForCall(call({ kind: 'binding', bindingId: 'outer' }, 'outer'))).toEqual([
|
||||
'/api/items',
|
||||
]);
|
||||
});
|
||||
|
||||
it('substitutes only a precisely resolved URL-builder declaration', () => {
|
||||
const builder = definition('builder', {
|
||||
filePath: 'other.ts',
|
||||
functionName: 'build',
|
||||
exportedNames: ['build'],
|
||||
urlReturn: { kind: 'concat', parts: ['/private', parameter] },
|
||||
});
|
||||
const make = (target: CalleeTarget) =>
|
||||
definition('request', {
|
||||
seed: true,
|
||||
urlArguments: [
|
||||
{
|
||||
callee: sink,
|
||||
fetch: true,
|
||||
argument: { kind: 'call', callee: { callee: 'build', target }, argument: parameter },
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const target of [
|
||||
{ kind: 'import', specifier: 'external-http', importedName: 'build' },
|
||||
{ kind: 'unresolved' },
|
||||
] as const)
|
||||
expect(
|
||||
buildFetchWrapperIndex([builder, make(target)]).urlsForCall(
|
||||
call({ kind: 'binding', bindingId: 'request' }),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
buildFetchWrapperIndex([
|
||||
builder,
|
||||
make({ kind: 'resolved-import', filePath: 'other.ts', importedName: 'build' }),
|
||||
]).urlsForCall(call({ kind: 'binding', bindingId: 'request' })),
|
||||
).toEqual(['/private/api/items']);
|
||||
});
|
||||
|
||||
it('preserves deep identity forwarding without spending the transformation depth budget', () => {
|
||||
const definitions = Array.from({ length: 5000 }, (_, i) =>
|
||||
definition(`f${i}`, {
|
||||
functionName: `f${i}`,
|
||||
...(i === 4999
|
||||
? {
|
||||
seed: true,
|
||||
forwardsUrl: true,
|
||||
urlArguments: [{ callee: sink, fetch: true, argument: parameter }],
|
||||
}
|
||||
: { urlArguments: [{ callee: ref(`f${i + 1}`), fetch: false, argument: parameter }] }),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
buildFetchWrapperIndex(definitions).urlsForCall(
|
||||
call({ kind: 'binding', bindingId: 'f0' }, 'f0'),
|
||||
),
|
||||
).toEqual(['/api/items']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue