mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat(routes): support JS data route tables (#2972)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
This commit is contained in:
parent
fe3d7e56be
commit
7f0ab16ffe
22 changed files with 3332 additions and 75 deletions
1
.github/workflows/ci-tests.yml
vendored
1
.github/workflows/ci-tests.yml
vendored
|
|
@ -724,6 +724,7 @@ jobs:
|
|||
test/integration/cobol-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-pipeline-benchmark.test.ts
|
||||
test/integration/cpp-adl-benchmark.test.ts
|
||||
test/integration/data-route-table-benchmark.test.ts
|
||||
test/integration/instance-ownership-pipeline-benchmark.test.ts
|
||||
test/integration/spring-bean-resource-benchmark.test.ts
|
||||
test/integration/rust-pipeline-benchmark.test.ts
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to
|
|||
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
|
||||
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
|
||||
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
|
||||
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS dispatch guards — see below) |
|
||||
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS static route sources — see below) |
|
||||
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
|
||||
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
|
||||
|
|
@ -174,7 +174,7 @@ converging on the routes phase's `(method, url)` registry:
|
|||
| Filesystem convention | path → URL, no parsing | Next.js `app/`, Expo, PHP |
|
||||
| Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` |
|
||||
| Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` |
|
||||
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS, **JS/TS dispatch guards** |
|
||||
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS, **JS/TS dispatch guards and static data route tables** |
|
||||
|
||||
The last row is the one whose name undersells it. A route is DECLARED by a
|
||||
decorator, but it can also be **inferred** from a raw `node:http` server's own
|
||||
|
|
@ -185,6 +185,15 @@ handler resolution are shared with decorator routes, and
|
|||
`ExtractedDecoratorRoute.source` carries the provenance difference through to
|
||||
the `HANDLES_ROUTE` edge.
|
||||
|
||||
JS/TS data route tables share that transport when a route-named array contains
|
||||
direct object literals with static `path`, `method`, and `handler` fields and a
|
||||
same-scope `for...of` dispatcher positively compares the path and method before
|
||||
directly invoking the handler. Dynamic values, computed keys, spreads,
|
||||
inline/called handlers, unknown verbs, and ambiguous handler bindings are
|
||||
suppressed. Bare import aliases and single-level member handlers are attributed
|
||||
only through declared import and owner provenance; an unproven receiver never
|
||||
falls back to a global name guess.
|
||||
|
||||
That extractor is deliberately **precision-weighted**: `route_map` presents its
|
||||
output as fact, so a `startsWith` namespace test, a bare `pathname === '/'`
|
||||
without a verb, and any regex it cannot translate exactly are all dropped rather
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import {
|
|||
type PatternSpec,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
||||
import {
|
||||
DATA_ROUTE_TABLE_SOURCE,
|
||||
scanDataRouteTables,
|
||||
} from '../../../ingestion/route-extractors/data-route-table.js';
|
||||
|
||||
/**
|
||||
* Node.js / TypeScript HTTP plugin family. Handles:
|
||||
|
|
@ -544,6 +548,26 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
|||
});
|
||||
}
|
||||
|
||||
for (const route of scanDataRouteTables(tree)) {
|
||||
const imported =
|
||||
route.handlerLocalName === undefined ? undefined : importMap.get(route.handlerLocalName);
|
||||
out.push({
|
||||
role: 'provider',
|
||||
framework: DATA_ROUTE_TABLE_SOURCE,
|
||||
method: route.method,
|
||||
path: route.path,
|
||||
// A source-only scan can prove a bare local/imported binding. Member
|
||||
// ownership needs the semantic model, so leave it unattributed here;
|
||||
// the graph-backed path consumes the exact handlerSymbolId later.
|
||||
name: imported?.name ?? (route.handlerLocalName === undefined ? null : route.handlerName),
|
||||
...(imported === undefined ? {} : { handlerImport: imported }),
|
||||
strictHandlerResolution: true,
|
||||
...(route.handlerLocalName === undefined ? { unresolvedHandler: true } : {}),
|
||||
line: route.line,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@ export interface HttpDetection {
|
|||
* locally-defined or anonymous handlers.
|
||||
*/
|
||||
handlerImport?: { name: string; module: string };
|
||||
/** Resolve only from the registration file or exact import target; never guess repo-wide. */
|
||||
strictHandlerResolution?: boolean;
|
||||
/**
|
||||
* The plugin saw a provider handler designator but could not prove its owner.
|
||||
* Prevents the orchestrator from treating it as an anonymous inline handler
|
||||
* and attributing it to the containing registrar function.
|
||||
*/
|
||||
unresolvedHandler?: boolean;
|
||||
/** Confidence in (0, 1]. Source-scan plugins typically use 0.7–0.8. */
|
||||
confidence: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { readSafe } from './fs-utils.js';
|
|||
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
||||
import { toZeroBasedLine } from '../../ingestion/utils/line-base.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from '../../ingestion/route-extractors/data-route-table.js';
|
||||
import {
|
||||
getPluginForFile,
|
||||
HTTP_SCAN_GLOB,
|
||||
|
|
@ -115,7 +116,7 @@ LIMIT 2`;
|
|||
|
||||
// Resolve an IMPORTED handler by pinning it to the import's target module: the
|
||||
// declared export `$name` whose file is the module the handler was imported from
|
||||
// (`$fileDot` matches `mod.ext`, `$fileSlash` matches `mod/index.ext`). This is
|
||||
// (`$filePaths` contains exact source-file and directory-index candidates). This is
|
||||
// the precise rung — it survives aliases and local same-name collisions that a
|
||||
// repo-wide name lookup cannot, and only resolves on a unique match within that
|
||||
// module. `LIMIT 2` keeps the uniqueness count exact (see RESOLVE_BY_NAME_QUERY).
|
||||
|
|
@ -129,13 +130,22 @@ MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
|
|||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
LIMIT 2`;
|
||||
|
||||
// determinism: probe — uniqueness discriminator, not a window. The consumer
|
||||
// accepts exactly one row and rejects a 2-row result whole, so row identity
|
||||
// cannot affect the resolution decision.
|
||||
export const RESOLVE_IN_EXACT_MODULE_QUERY = `
|
||||
MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
|
||||
AND n.name = $name AND n.filePath IN $filePaths
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
LIMIT 2`;
|
||||
|
||||
// Source-file extensions an import specifier may resolve to (stripped before
|
||||
// building the module file-prefix so `./h/users` and `./h/users.ts` agree).
|
||||
const SOURCE_EXT_RE = /\.(?:m|c)?[jt]sx?$/;
|
||||
|
||||
/**
|
||||
* Resolve an import specifier to a repo-relative FILE BASE (path without
|
||||
* extension) so the target module can be matched by `filePath STARTS WITH`.
|
||||
* extension) so exact target-file candidates can be constructed.
|
||||
* Handles two relative-import dialects and returns null for bare/absolute
|
||||
* imports (which fall back to a repo-wide name lookup):
|
||||
* - path-style (JS/TS): `./handlers/users`, `../x` → joined against the
|
||||
|
|
@ -161,6 +171,27 @@ function resolveModuleBase(fromFile: string, module: string): string | null {
|
|||
return null; // bare / absolute import — repo-wide fallback
|
||||
}
|
||||
|
||||
const MODULE_SOURCE_EXTENSIONS = [
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.mts',
|
||||
'.cts',
|
||||
'.js',
|
||||
'.jsx',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.py',
|
||||
];
|
||||
|
||||
function moduleFileCandidates(base: string): string[] {
|
||||
return [
|
||||
...MODULE_SOURCE_EXTENSIONS.map((extension) => `${base}${extension}`),
|
||||
...MODULE_SOURCE_EXTENSIONS.map((extension) =>
|
||||
extension === '.py' ? `${base}/__init__.py` : `${base}/index${extension}`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
interface ResolvedSymbol {
|
||||
uid: string;
|
||||
name: string;
|
||||
|
|
@ -229,6 +260,17 @@ function resolveSymbolByName(rows: Record<string, unknown>[], name: string): Res
|
|||
return null;
|
||||
}
|
||||
|
||||
function resolveFileSymbolByNameUnique(
|
||||
rows: Record<string, unknown>[],
|
||||
name: string,
|
||||
): ResolvedSymbol | null {
|
||||
const matches = rows
|
||||
.map((row) => resolveSymbolByName([row], name))
|
||||
.filter((match): match is ResolvedSymbol => match !== null);
|
||||
const byUid = new Map(matches.map((match) => [match.uid, match]));
|
||||
return byUid.size === 1 ? (byUid.values().next().value ?? null) : null;
|
||||
}
|
||||
|
||||
// ─── Path normalization (shared between provider / consumer paths) ──
|
||||
|
||||
/**
|
||||
|
|
@ -484,28 +526,29 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
globalNameCache.set(name, result);
|
||||
return result;
|
||||
};
|
||||
// Resolve a handler imported from a RELATIVE module to the unique declared
|
||||
// symbol of that name inside the import's target file. Returns null for
|
||||
// non-relative (bare/aliased-path) imports — those fall back to the repo-wide
|
||||
// name lookup. Cached by (target-file-prefix, declared name).
|
||||
// Resolve a handler imported from a relative module to the unique declared
|
||||
// symbol inside its target file. The caller decides whether a miss may use
|
||||
// the historical unique repository-wide fallback.
|
||||
const importedSymbolCache = new Map<string, ResolvedSymbol | null>();
|
||||
const resolveImportedSymbol = async (
|
||||
fromFile: string,
|
||||
imp: { name: string; module: string },
|
||||
strict = false,
|
||||
): Promise<ResolvedSymbol | null> => {
|
||||
if (!dbExecutor) return null;
|
||||
const base = resolveModuleBase(fromFile, imp.module);
|
||||
if (base === null) return null; // bare/absolute import → repo-wide fallback
|
||||
const cacheKey = JSON.stringify([base, imp.name]);
|
||||
if (base === null) return null;
|
||||
const cacheKey = JSON.stringify([base, imp.name, strict]);
|
||||
const cached = importedSymbolCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
let rows: Record<string, unknown>[] = [];
|
||||
try {
|
||||
rows = await dbExecutor(RESOLVE_IN_MODULE_QUERY, {
|
||||
name: imp.name,
|
||||
fileDot: `${base}.`,
|
||||
fileSlash: `${base}/`,
|
||||
});
|
||||
rows = await dbExecutor(
|
||||
strict ? RESOLVE_IN_EXACT_MODULE_QUERY : RESOLVE_IN_MODULE_QUERY,
|
||||
strict
|
||||
? { name: imp.name, filePaths: moduleFileCandidates(base) }
|
||||
: { name: imp.name, fileDot: `${base}.`, fileSlash: `${base}/` },
|
||||
);
|
||||
} catch {
|
||||
rows = [];
|
||||
}
|
||||
|
|
@ -518,6 +561,7 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
d: HttpDetection,
|
||||
): Promise<ResolvedSymbol | null> => {
|
||||
if (!dbExecutor) return null;
|
||||
if (d.role === 'provider' && d.unresolvedHandler) return null;
|
||||
const syms = await loadFileSymbols(filePath);
|
||||
// Name resolution does NOT need a detection line — a named provider
|
||||
// handler (Spring/Go/etc. method name) resolves by name even when the
|
||||
|
|
@ -531,14 +575,20 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
// its (declared) name would be wrong; on a miss go straight to a unique
|
||||
// repo-wide match on the declared name, never file-scoped.
|
||||
if (d.handlerImport) {
|
||||
const byImport = await resolveImportedSymbol(filePath, d.handlerImport);
|
||||
const byImport = await resolveImportedSymbol(
|
||||
filePath,
|
||||
d.handlerImport,
|
||||
d.strictHandlerResolution,
|
||||
);
|
||||
if (byImport) return byImport;
|
||||
const byGlobal = await resolveSymbolByNameUnique(d.handlerImport.name);
|
||||
if (byGlobal) return byGlobal;
|
||||
return null;
|
||||
if (d.strictHandlerResolution) return null;
|
||||
return resolveSymbolByNameUnique(d.handlerImport.name);
|
||||
}
|
||||
const byName = resolveSymbolByName(syms, d.name);
|
||||
const byName = d.strictHandlerResolution
|
||||
? resolveFileSymbolByNameUnique(syms, d.name)
|
||||
: resolveSymbolByName(syms, d.name);
|
||||
if (byName) return byName;
|
||||
if (d.strictHandlerResolution) return null;
|
||||
const byGlobal = await resolveSymbolByNameUnique(d.name);
|
||||
if (byGlobal) return byGlobal;
|
||||
// A NAMED handler we could not resolve by name (neither file-scoped nor
|
||||
|
|
@ -789,7 +839,12 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
getDetections: (rel: string) => Promise<HttpDetection[]>,
|
||||
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
const candidates: Array<{
|
||||
detection: HttpDetection;
|
||||
filePath: string;
|
||||
pathNorm: string;
|
||||
resolved: ResolvedSymbol | null;
|
||||
}> = [];
|
||||
for (const rel of files) {
|
||||
const detections = await getDetections(rel);
|
||||
const filePath = normalizeRepoRelPath(rel);
|
||||
|
|
@ -800,27 +855,57 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
// arrow that encloses the registration line) so the contract carries a
|
||||
// real symbolUid; fall back to the file + detection name otherwise.
|
||||
const resolved = await resolveSymbol(filePath, d);
|
||||
out.push({
|
||||
contractId: contractIdFor(d.method, pathNorm),
|
||||
type: 'http',
|
||||
role: 'provider',
|
||||
symbolUid: resolved?.uid ?? '',
|
||||
symbolRef: {
|
||||
filePath: resolved?.filePath || filePath,
|
||||
name: resolved?.name ?? d.name ?? 'handler',
|
||||
},
|
||||
symbolName: resolved?.name ?? d.name ?? 'handler',
|
||||
confidence: d.confidence,
|
||||
meta: {
|
||||
method: d.method,
|
||||
path: pathNorm,
|
||||
pathSegments: pathNorm.split('/').filter(Boolean),
|
||||
extractionStrategy: resolved ? 'source_scan_resolved' : 'source_scan',
|
||||
framework: d.framework,
|
||||
},
|
||||
});
|
||||
candidates.push({ detection: d, filePath, pathNorm, resolved });
|
||||
}
|
||||
}
|
||||
|
||||
const dataCandidatesByIdentity = new Map<string, typeof candidates>();
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.detection.framework !== DATA_ROUTE_TABLE_SOURCE) continue;
|
||||
const identity = contractIdFor(candidate.detection.method, candidate.pathNorm);
|
||||
const grouped = dataCandidatesByIdentity.get(identity) ?? [];
|
||||
grouped.push(candidate);
|
||||
dataCandidatesByIdentity.set(identity, grouped);
|
||||
}
|
||||
const ambiguousDataIdentities = new Set<string>();
|
||||
for (const [identity, grouped] of dataCandidatesByIdentity) {
|
||||
if (grouped.length < 2) continue;
|
||||
const resolvedIds = new Set(
|
||||
grouped.flatMap((candidate) =>
|
||||
candidate.resolved === null ? [] : [candidate.resolved.uid],
|
||||
),
|
||||
);
|
||||
if (grouped.some((candidate) => candidate.resolved === null) || resolvedIds.size !== 1) {
|
||||
ambiguousDataIdentities.add(identity);
|
||||
}
|
||||
}
|
||||
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const { detection: d, filePath, pathNorm, resolved } of candidates) {
|
||||
const contractId = contractIdFor(d.method, pathNorm);
|
||||
if (d.framework === DATA_ROUTE_TABLE_SOURCE && ambiguousDataIdentities.has(contractId)) {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
contractId,
|
||||
type: 'http',
|
||||
role: 'provider',
|
||||
symbolUid: resolved?.uid ?? '',
|
||||
symbolRef: {
|
||||
filePath: resolved?.filePath || filePath,
|
||||
name: resolved?.name ?? d.name ?? 'handler',
|
||||
},
|
||||
symbolName: resolved?.name ?? d.name ?? 'handler',
|
||||
confidence: d.confidence,
|
||||
meta: {
|
||||
method: d.method,
|
||||
path: pathNorm,
|
||||
pathSegments: pathNorm.split('/').filter(Boolean),
|
||||
extractionStrategy: resolved ? 'source_scan_resolved' : 'source_scan',
|
||||
framework: d.framework,
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.dedupeContracts(out);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import type { SemanticModel, SymbolTableReader } from './model/index.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ParsedImport, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
routeNodeKey,
|
||||
} from './route-extractors/route-path.js';
|
||||
import { extractReturnTypeName } from './type-extractors/shared.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from './route-extractors/data-route-table.js';
|
||||
|
||||
const MAX_EXPORTS_PER_FILE = 500;
|
||||
const MAX_TYPE_NAME_LENGTH = 256;
|
||||
|
|
@ -37,6 +38,18 @@ const MAX_TYPE_NAME_LENGTH = 256;
|
|||
* Consumed by the cross-file re-resolution / enrichment pass. */
|
||||
export type ExportedTypeMap = Map<string, Map<string, string>>;
|
||||
|
||||
interface RouteResolutionFile {
|
||||
readonly filePath: string;
|
||||
readonly parsedImports: readonly ParsedImport[];
|
||||
readonly localDefs: readonly SymbolDefinition[];
|
||||
}
|
||||
|
||||
interface RouteHandlerResolutionContext {
|
||||
readonly files: readonly RouteResolutionFile[];
|
||||
readonly resolveImportTarget: (parsedImport: ParsedImport, fromFile: string) => string | null;
|
||||
readonly isExportedSymbol: (nodeId: string) => boolean;
|
||||
}
|
||||
|
||||
/** Record one exported graph node into the incremental ExportedTypeMap. */
|
||||
export const accumulateExportedTypesFromParsedNode = (
|
||||
result: ExportedTypeMap,
|
||||
|
|
@ -281,6 +294,7 @@ export function resolveRouteHandlerSymbols(
|
|||
model: SemanticModel,
|
||||
extractedRoutes: readonly ExtractedRoute[],
|
||||
decoratorRoutes: readonly ExtractedDecoratorRoute[],
|
||||
routeContext?: RouteHandlerResolutionContext,
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
// Route identities already claimed by an earlier route (resolved or not).
|
||||
|
|
@ -295,6 +309,97 @@ export function resolveRouteHandlerSymbols(
|
|||
return defs.length === 1 ? defs[0]?.nodeId : undefined;
|
||||
};
|
||||
|
||||
const uniqueById = (defs: readonly SymbolDefinition[]): SymbolDefinition | undefined => {
|
||||
const byId = new Map(defs.map((def) => [def.nodeId, def]));
|
||||
return byId.size === 1 ? byId.values().next().value : undefined;
|
||||
};
|
||||
|
||||
const routeCallables = (defs: readonly SymbolDefinition[]): readonly SymbolDefinition[] =>
|
||||
defs.filter((def) => def.type === 'Function' || def.type === 'Method');
|
||||
|
||||
const exportedRouteCallables = (
|
||||
defs: readonly SymbolDefinition[],
|
||||
): readonly SymbolDefinition[] =>
|
||||
routeContext === undefined
|
||||
? []
|
||||
: routeCallables(defs).filter((def) => routeContext.isExportedSymbol(def.nodeId));
|
||||
|
||||
const filesByPath = new Map(routeContext?.files.map((file) => [file.filePath, file]) ?? []);
|
||||
|
||||
const uniqueImport = (filePath: string, localName: string): ParsedImport | undefined => {
|
||||
const matches = (filesByPath.get(filePath)?.parsedImports ?? []).filter(
|
||||
(parsedImport) =>
|
||||
'localName' in parsedImport &&
|
||||
parsedImport.localName === localName &&
|
||||
parsedImport.kind !== 'dynamic-unresolved',
|
||||
);
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
};
|
||||
|
||||
const importedTarget = (
|
||||
filePath: string,
|
||||
localName: string,
|
||||
): { parsedImport: ParsedImport; targetFile: string } | undefined => {
|
||||
if (routeContext === undefined) return undefined;
|
||||
const parsedImport = uniqueImport(filePath, localName);
|
||||
if (parsedImport === undefined) return undefined;
|
||||
const targetFile = routeContext.resolveImportTarget(parsedImport, filePath);
|
||||
return targetFile === null ? undefined : { parsedImport, targetFile };
|
||||
};
|
||||
|
||||
const resolveDataRouteHandler = (filePath: string, designator: string): string | undefined => {
|
||||
const parts = designator.split('.');
|
||||
if (parts.length === 1) {
|
||||
const local = uniqueById(routeCallables(model.symbols.lookupExactAll(filePath, designator)));
|
||||
if (local !== undefined) return local.nodeId;
|
||||
|
||||
const imported = importedTarget(filePath, designator);
|
||||
if (
|
||||
imported === undefined ||
|
||||
imported.parsedImport.kind === 'namespace' ||
|
||||
imported.parsedImport.kind === 'wildcard' ||
|
||||
!('importedName' in imported.parsedImport)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (imported.parsedImport.importedName === 'default') {
|
||||
// ParsedFile does not carry explicit default-export provenance. Fail
|
||||
// closed rather than infer an unrelated named export from the module.
|
||||
return undefined;
|
||||
}
|
||||
return uniqueById(
|
||||
exportedRouteCallables(
|
||||
model.symbols.lookupExactAll(imported.targetFile, imported.parsedImport.importedName),
|
||||
),
|
||||
)?.nodeId;
|
||||
}
|
||||
if (parts.length !== 2) return undefined;
|
||||
|
||||
const [receiver, member] = parts;
|
||||
const localOwner = uniqueById(model.symbols.lookupExactAll(filePath, receiver));
|
||||
if (localOwner !== undefined) {
|
||||
return uniqueById(model.methods.lookupAllByOwner(localOwner.nodeId, member))?.nodeId;
|
||||
}
|
||||
|
||||
const imported = importedTarget(filePath, receiver);
|
||||
if (imported === undefined || imported.parsedImport.kind === 'wildcard') return undefined;
|
||||
if (imported.parsedImport.kind === 'namespace') {
|
||||
return uniqueById(
|
||||
exportedRouteCallables(model.symbols.lookupExactAll(imported.targetFile, member)),
|
||||
)?.nodeId;
|
||||
}
|
||||
if (!('importedName' in imported.parsedImport)) return undefined;
|
||||
if (imported.parsedImport.importedName === 'default') return undefined;
|
||||
const owner = uniqueById(
|
||||
model.symbols
|
||||
.lookupExactAll(imported.targetFile, imported.parsedImport.importedName)
|
||||
.filter((def) => routeContext?.isExportedSymbol(def.nodeId) === true),
|
||||
);
|
||||
return owner === undefined
|
||||
? undefined
|
||||
: uniqueById(model.methods.lookupAllByOwner(owner.nodeId, member))?.nodeId;
|
||||
};
|
||||
|
||||
const claim = (
|
||||
routePath: string | null,
|
||||
prefix: string | null,
|
||||
|
|
@ -326,10 +431,49 @@ export function resolveRouteHandlerSymbols(
|
|||
claim(route.routePath, route.prefix ?? null, route.httpMethod, methodId);
|
||||
}
|
||||
|
||||
// Decorator routes (Spring / FastAPI / generic) — the decorated handler in
|
||||
// the route's own file.
|
||||
const dataHandlerByRoute = new Map<ExtractedDecoratorRoute, string>();
|
||||
const dataHandlersByIdentity = new Map<
|
||||
string,
|
||||
{ handlers: Set<string>; hasUnresolved: boolean }
|
||||
>();
|
||||
for (const dr of decoratorRoutes) {
|
||||
const handlerId = dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined;
|
||||
if (dr.source !== DATA_ROUTE_TABLE_SOURCE || !dr.handlerName || !dr.routePath) continue;
|
||||
const handlerId = resolveDataRouteHandler(dr.filePath, dr.handlerName);
|
||||
const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null);
|
||||
const key = routeNodeKey(normalizeRouteMethod(dr.httpMethod), url);
|
||||
const state = dataHandlersByIdentity.get(key) ?? {
|
||||
handlers: new Set<string>(),
|
||||
hasUnresolved: false,
|
||||
};
|
||||
if (handlerId === undefined) {
|
||||
state.hasUnresolved = true;
|
||||
} else {
|
||||
dataHandlerByRoute.set(dr, handlerId);
|
||||
state.handlers.add(handlerId);
|
||||
}
|
||||
dataHandlersByIdentity.set(key, state);
|
||||
}
|
||||
|
||||
// Decorator routes (Spring / FastAPI / generic) — the decorated handler in
|
||||
// the route's own file. Data tables additionally suppress an identity when
|
||||
// duplicate entries resolve to different handlers: recording either one
|
||||
// would invent a single-winner dispatch that the loop does not prove.
|
||||
for (const dr of decoratorRoutes) {
|
||||
const handlerId =
|
||||
dr.source === DATA_ROUTE_TABLE_SOURCE
|
||||
? dataHandlerByRoute.get(dr)
|
||||
: dr.handlerName
|
||||
? uniqueSymbolId(dr.filePath, dr.handlerName)
|
||||
: undefined;
|
||||
// An unproven data-table entry never becomes a Route node, so it must not
|
||||
// reserve the identity and suppress a later, valid framework declaration.
|
||||
if (dr.source === DATA_ROUTE_TABLE_SOURCE && handlerId === undefined) continue;
|
||||
if (dr.source === DATA_ROUTE_TABLE_SOURCE && dr.routePath) {
|
||||
const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null);
|
||||
const key = routeNodeKey(normalizeRouteMethod(dr.httpMethod), url);
|
||||
const state = dataHandlersByIdentity.get(key);
|
||||
if (state === undefined || state.hasUnresolved || state.handlers.size !== 1) continue;
|
||||
}
|
||||
claim(dr.routePath, dr.prefix ?? null, dr.httpMethod, handlerId);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,12 @@ import {
|
|||
jsArityCompatibility,
|
||||
} from './javascript/index.js';
|
||||
import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
|
||||
import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js';
|
||||
|
||||
const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes>) => [
|
||||
...extractDispatchGuardRoutes(...args),
|
||||
...extractDataRouteTableRoutes(...args),
|
||||
];
|
||||
|
||||
/**
|
||||
* TypeScript/JavaScript: arrow_function and function_expression are
|
||||
|
|
@ -458,7 +464,7 @@ export const typescriptProvider = defineLanguage({
|
|||
// A raw `node:http` server declares its routes by comparing the request path
|
||||
// to a literal; nothing else in this pipeline can see that shape. TS and JS
|
||||
// share the grammar, so they share the extractor.
|
||||
extractDecoratorRoutes: extractDispatchGuardRoutes,
|
||||
extractDecoratorRoutes: extractJsTsRoutes,
|
||||
});
|
||||
|
||||
export const javascriptProvider = defineLanguage({
|
||||
|
|
@ -532,5 +538,5 @@ export const javascriptProvider = defineLanguage({
|
|||
receiverBinding: jsReceiverBinding,
|
||||
arityCompatibility: jsArityCompatibility,
|
||||
// See the TypeScript provider above.
|
||||
extractDecoratorRoutes: extractDispatchGuardRoutes,
|
||||
extractDecoratorRoutes: extractJsTsRoutes,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
import {
|
||||
clearParsedFileStore,
|
||||
persistParsedFileChunk,
|
||||
loadParsedFilesForPaths,
|
||||
getDurableParsedFileDir,
|
||||
loadDurableParsedFileIndex,
|
||||
prepareDurableParsedFileChunk,
|
||||
|
|
@ -49,6 +50,7 @@ import { createSemanticModel, type MutableSemanticModel } from '../model/index.j
|
|||
import {
|
||||
type PipelineProgress,
|
||||
getLanguageFromFilename,
|
||||
type ParsedImport,
|
||||
SupportedLanguages,
|
||||
} from 'gitnexus-shared';
|
||||
import { readFileContents } from '../filesystem-walker.js';
|
||||
|
|
@ -59,6 +61,8 @@ import {
|
|||
} from '../../tree-sitter/parser-loader.js';
|
||||
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
||||
import { getProvider, providers } from '../languages/index.js';
|
||||
import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
import {
|
||||
createWorkerPool,
|
||||
|
|
@ -1521,12 +1525,69 @@ export async function runChunkedParseAndResolve(
|
|||
'parse-impl-return',
|
||||
`exportedTypeMap=${exportedTypeMap.size} parsedFiles=${allParsedFiles.length} nodes=${graph.nodeCount}`,
|
||||
);
|
||||
const routeFilePaths = new Set(allPaths);
|
||||
const dataRouteFilePaths = new Set(
|
||||
allDecoratorRoutes
|
||||
.filter((route) => route.source === DATA_ROUTE_TABLE_SOURCE)
|
||||
.map((route) => route.filePath),
|
||||
);
|
||||
const routeResolutionConfigs = new Map<SupportedLanguages, unknown>();
|
||||
for (const filePath of dataRouteFilePaths) {
|
||||
const language = getLanguageFromFilename(filePath);
|
||||
if (language === null || routeResolutionConfigs.has(language)) continue;
|
||||
const resolver = SCOPE_RESOLVERS.get(language);
|
||||
routeResolutionConfigs.set(
|
||||
language,
|
||||
resolver?.loadResolutionConfig === undefined
|
||||
? undefined
|
||||
: await resolver.loadResolutionConfig(repoPath),
|
||||
);
|
||||
}
|
||||
let routeResolutionFiles = allParsedFiles;
|
||||
const resolveRouteImportTarget = (
|
||||
parsedImport: ParsedImport,
|
||||
fromFile: string,
|
||||
): string | null => {
|
||||
const language = getLanguageFromFilename(fromFile);
|
||||
if (language === null) return null;
|
||||
const target = SCOPE_RESOLVERS.get(language)?.resolveImportTarget(
|
||||
parsedImport.targetRaw ?? '',
|
||||
fromFile,
|
||||
routeFilePaths,
|
||||
routeResolutionConfigs.get(language),
|
||||
{ parsedFiles: routeResolutionFiles, parsedImport },
|
||||
);
|
||||
if (typeof target === 'string') return target;
|
||||
return target?.length === 1 ? target[0] : null;
|
||||
};
|
||||
if (parsedFileStorePath !== undefined && dataRouteFilePaths.size > 0) {
|
||||
const byPath = await loadParsedFilesForPaths(parsedFileStorePath, dataRouteFilePaths);
|
||||
for (const parsed of allParsedFiles) {
|
||||
if (dataRouteFilePaths.has(parsed.filePath)) byPath.set(parsed.filePath, parsed);
|
||||
}
|
||||
routeResolutionFiles = [...byPath.values()];
|
||||
const directTargets = new Set<string>();
|
||||
for (const parsed of routeResolutionFiles) {
|
||||
for (const parsedImport of parsed.parsedImports) {
|
||||
const target = resolveRouteImportTarget(parsedImport, parsed.filePath);
|
||||
if (target !== null) directTargets.add(target);
|
||||
}
|
||||
}
|
||||
const importedFiles = await loadParsedFilesForPaths(parsedFileStorePath, directTargets);
|
||||
for (const parsed of importedFiles.values()) byPath.set(parsed.filePath, parsed);
|
||||
routeResolutionFiles = [...byPath.values()];
|
||||
}
|
||||
// Part 2 (#2138): resolve each route's handler to a real symbol UID now that
|
||||
// the model is fully populated and decorator-route prefixes are finalized.
|
||||
const routeHandlerSymbols = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
allExtractedRoutes,
|
||||
allDecoratorRoutes,
|
||||
{
|
||||
files: routeResolutionFiles,
|
||||
resolveImportTarget: resolveRouteImportTarget,
|
||||
isExportedSymbol: (nodeId: string) => graph.getNode(nodeId)?.properties.isExported === true,
|
||||
},
|
||||
);
|
||||
return {
|
||||
exportedTypeMap,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
} from '../route-extractors/middleware.js';
|
||||
import { processNextjsFetchRoutes } from '../call-processor.js';
|
||||
import { reconcileDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
|
||||
import {
|
||||
normalizeExtractedRoutePath,
|
||||
normalizeRouteMethod,
|
||||
|
|
@ -76,6 +77,29 @@ export interface TemplateFetchCall {
|
|||
lineNumber: number;
|
||||
}
|
||||
|
||||
function handlerSymbolContent(
|
||||
content: string,
|
||||
handlerNode: { properties: Record<string, unknown> } | undefined,
|
||||
): string | undefined {
|
||||
if (handlerNode === undefined) return undefined;
|
||||
const startLine = handlerNode.properties.startLine;
|
||||
const endLine = handlerNode.properties.endLine;
|
||||
if (
|
||||
typeof startLine !== 'number' ||
|
||||
typeof endLine !== 'number' ||
|
||||
!Number.isInteger(startLine) ||
|
||||
!Number.isInteger(endLine) ||
|
||||
startLine < 0 ||
|
||||
endLine < startLine
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.slice(startLine, endLine + 1)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const TEMPLATE_URL_PATTERNS: readonly RegExp[] = [
|
||||
/\b(?:action|href)\s*=\s*["']([^"']+)["']/gi,
|
||||
/\burl\s*:\s*["']([^"']+)["'](?!\s*\+)/g,
|
||||
|
|
@ -255,36 +279,57 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
// their verb-less form is a declaration, not a weaker observation.
|
||||
for (const dr of reconcileDispatchGuardRoutes(allDecoratorRoutes)) {
|
||||
const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null);
|
||||
const method = normalizeRouteMethod(dr.httpMethod);
|
||||
const routeKey = routeNodeKey(method, url);
|
||||
// A data-table entry is only a provider route once its static handler has
|
||||
// been proven. Other route sources retain their historical fallback.
|
||||
if (dr.source === DATA_ROUTE_TABLE_SOURCE && !routeHandlerSymbols.has(routeKey)) continue;
|
||||
addRoute(url, {
|
||||
filePath: dr.filePath,
|
||||
// A route extracted from a file's own AST is usually a decorator; a
|
||||
// dispatch guard is the same transport with different provenance, and
|
||||
// says so (`ExtractedDecoratorRoute.source`).
|
||||
source: dr.source ?? `decorator-${dr.decoratorName}`,
|
||||
method: normalizeRouteMethod(dr.httpMethod),
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
let handlerContents: Map<string, string> | undefined;
|
||||
if (routeRegistry.size > 0) {
|
||||
const handlerPaths = [...routeRegistry.values()].map((e) => e.filePath);
|
||||
const handlerPathFor = (routeKey: string, entry: RouteEntry): string => {
|
||||
if (entry.source !== DATA_ROUTE_TABLE_SOURCE) return entry.filePath;
|
||||
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
|
||||
const resolvedPath = handlerSymbolId
|
||||
? ctx.graph.getNode(handlerSymbolId)?.properties.filePath
|
||||
: undefined;
|
||||
return typeof resolvedPath === 'string' ? resolvedPath : entry.filePath;
|
||||
};
|
||||
const handlerPaths = [...routeRegistry].map(([key, entry]) => handlerPathFor(key, entry));
|
||||
handlerContents = await readFileContents(ctx.repoPath, handlerPaths);
|
||||
|
||||
for (const [routeKey, entry] of routeRegistry) {
|
||||
const { filePath: handlerPath, source: routeSource, method: routeMethod, url } = entry;
|
||||
const { source: routeSource, method: routeMethod, url } = entry;
|
||||
const handlerPath = handlerPathFor(routeKey, entry);
|
||||
const content = handlerContents.get(handlerPath);
|
||||
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
|
||||
const analysisContent =
|
||||
entry.source === DATA_ROUTE_TABLE_SOURCE && content
|
||||
? handlerSymbolContent(
|
||||
content,
|
||||
handlerSymbolId ? ctx.graph.getNode(handlerSymbolId) : undefined,
|
||||
)
|
||||
: content;
|
||||
|
||||
const { responseKeys, errorKeys } = content
|
||||
const { responseKeys, errorKeys } = analysisContent
|
||||
? handlerPath.endsWith('.php')
|
||||
? extractPHPResponseShapes(content)
|
||||
: extractResponseShapes(content)
|
||||
? extractPHPResponseShapes(analysisContent)
|
||||
: extractResponseShapes(analysisContent)
|
||||
: { responseKeys: undefined, errorKeys: undefined };
|
||||
|
||||
const mwResult = content ? extractMiddlewareChain(content) : undefined;
|
||||
const mwResult = analysisContent ? extractMiddlewareChain(analysisContent) : undefined;
|
||||
const middleware = mwResult?.chain;
|
||||
|
||||
const routeNodeId = generateId('Route', routeKey);
|
||||
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
|
||||
ctx.graph.addNode({
|
||||
id: routeNodeId,
|
||||
label: 'Route',
|
||||
|
|
|
|||
959
gitnexus/src/core/ingestion/route-extractors/data-route-table.ts
Normal file
959
gitnexus/src/core/ingestion/route-extractors/data-route-table.ts
Normal file
|
|
@ -0,0 +1,959 @@
|
|||
/**
|
||||
* Conservative extraction for explicit JavaScript-style route tables.
|
||||
*
|
||||
* A generic object containing `path`, `method`, and `handler` can also be an
|
||||
* HTTP client request descriptor. Extraction therefore requires both a
|
||||
* route-named binding and a static `for (... of table)` dispatch loop whose
|
||||
* request guard compares the entry's path and method before directly invoking
|
||||
* its handler.
|
||||
*/
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { SyntaxNode } from 'tree-sitter';
|
||||
import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
|
||||
|
||||
export const DATA_ROUTE_TABLE_SOURCE = 'data-route-table';
|
||||
|
||||
const ROUTE_BINDING_HINT = /route/i;
|
||||
const ROUTE_TOKEN_HINTS = ['path', 'method', 'handler'] as const;
|
||||
const HTTP_METHODS: ReadonlySet<string> = new Set([
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'DELETE',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
]);
|
||||
|
||||
export interface DataRouteTableRoute {
|
||||
path: string;
|
||||
method: string;
|
||||
/** Full static handler designator, e.g. `auth.getCurrentUser`. */
|
||||
handlerDesignator: string;
|
||||
handlerName: string;
|
||||
/** Present only for a bare handler identifier, for named-import resolution. */
|
||||
handlerLocalName?: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
const SIMPLE_STRING_ESCAPES: Readonly<Record<string, string>> = {
|
||||
b: '\b',
|
||||
f: '\f',
|
||||
n: '\n',
|
||||
r: '\r',
|
||||
t: '\t',
|
||||
v: '\v',
|
||||
};
|
||||
|
||||
function decodeJavaScriptStringLiteral(raw: string): string | null {
|
||||
if (raw.length < 2) return null;
|
||||
const delimiter = raw[0];
|
||||
if (
|
||||
(delimiter !== "'" && delimiter !== '"' && delimiter !== '`') ||
|
||||
raw[raw.length - 1] !== delimiter
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = raw.slice(1, -1);
|
||||
let decoded = '';
|
||||
for (let index = 0; index < body.length; index++) {
|
||||
const char = body[index];
|
||||
if (char !== '\\') {
|
||||
if (delimiter !== '`' && (char === '\n' || char === '\r')) return null;
|
||||
decoded += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
const escaped = body[++index];
|
||||
if (escaped === undefined) return null;
|
||||
if (escaped === '\n' || escaped === '\u2028' || escaped === '\u2029') continue;
|
||||
if (escaped === '\r') {
|
||||
if (body[index + 1] === '\n') index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const simple = SIMPLE_STRING_ESCAPES[escaped];
|
||||
if (simple !== undefined) {
|
||||
decoded += simple;
|
||||
continue;
|
||||
}
|
||||
if (escaped === '0') {
|
||||
if (/\d/.test(body[index + 1] ?? '')) return null;
|
||||
decoded += '\0';
|
||||
continue;
|
||||
}
|
||||
if (/[1-9]/.test(escaped)) return null;
|
||||
if (escaped === 'x') {
|
||||
const hex = body.slice(index + 1, index + 3);
|
||||
if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return null;
|
||||
decoded += String.fromCharCode(Number.parseInt(hex, 16));
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (escaped === 'u') {
|
||||
if (body[index + 1] === '{') {
|
||||
const close = body.indexOf('}', index + 2);
|
||||
if (close === -1) return null;
|
||||
const hex = body.slice(index + 2, close);
|
||||
if (!/^[0-9A-Fa-f]{1,6}$/.test(hex)) return null;
|
||||
const codePoint = Number.parseInt(hex, 16);
|
||||
if (codePoint > 0x10ffff) return null;
|
||||
decoded += String.fromCodePoint(codePoint);
|
||||
index = close;
|
||||
continue;
|
||||
}
|
||||
const hex = body.slice(index + 1, index + 5);
|
||||
if (!/^[0-9A-Fa-f]{4}$/.test(hex)) return null;
|
||||
decoded += String.fromCharCode(Number.parseInt(hex, 16));
|
||||
index += 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
// JavaScript treats an escaped non-special character as that character.
|
||||
decoded += escaped;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function plainString(node: SyntaxNode): string | null {
|
||||
if (node.type === 'string') return decodeJavaScriptStringLiteral(node.text);
|
||||
if (
|
||||
node.type === 'template_string' &&
|
||||
node.namedChildren.every(
|
||||
(child) => child.type === 'string_fragment' || child.type === 'escape_sequence',
|
||||
)
|
||||
) {
|
||||
return decodeJavaScriptStringLiteral(node.text);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function propertyName(node: SyntaxNode): string | null {
|
||||
if (node.type === 'identifier' || node.type === 'property_identifier') return node.text;
|
||||
if (node.type === 'string') return plainString(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
function handlerName(
|
||||
node: SyntaxNode,
|
||||
): { designator: string; name: string; localName?: string } | null {
|
||||
if (node.type === 'identifier') {
|
||||
return { designator: node.text, name: node.text, localName: node.text };
|
||||
}
|
||||
if (node.type !== 'member_expression') return null;
|
||||
|
||||
const object = node.childForFieldName('object');
|
||||
const property = node.childForFieldName('property');
|
||||
if (
|
||||
object?.type !== 'identifier' ||
|
||||
property === null ||
|
||||
property.type !== 'property_identifier'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
designator: `${object.text}.${property.text}`,
|
||||
name: property.text,
|
||||
};
|
||||
}
|
||||
|
||||
const EXECUTING_VALUE_NODES = new Set([
|
||||
'assignment_expression',
|
||||
'augmented_assignment_expression',
|
||||
'await_expression',
|
||||
'call_expression',
|
||||
'new_expression',
|
||||
'spread_element',
|
||||
'update_expression',
|
||||
'yield_expression',
|
||||
]);
|
||||
|
||||
function containsExecutingExpression(node: SyntaxNode): boolean {
|
||||
if (EXECUTING_VALUE_NODES.has(node.type)) return true;
|
||||
if (node.type === 'unary_expression' && node.text.trimStart().startsWith('delete ')) return true;
|
||||
return node.namedChildren.some(containsExecutingExpression);
|
||||
}
|
||||
|
||||
function routeFromObject(node: SyntaxNode): DataRouteTableRoute | null {
|
||||
const values = new Map<string, SyntaxNode>();
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'comment') continue;
|
||||
// Spread properties, methods, computed keys, and other executable shapes
|
||||
// make the entry non-declarative, so the whole entry is suppressed.
|
||||
if (child.type !== 'pair') return null;
|
||||
const keyNode = child.childForFieldName('key');
|
||||
const valueNode = child.childForFieldName('value');
|
||||
if (keyNode === null || valueNode === null) return null;
|
||||
const key = propertyName(keyNode);
|
||||
if (key === null) return null;
|
||||
if (!ROUTE_TOKEN_HINTS.includes(key as (typeof ROUTE_TOKEN_HINTS)[number])) {
|
||||
if (containsExecutingExpression(valueNode)) return null;
|
||||
continue;
|
||||
}
|
||||
if (values.has(key)) return null;
|
||||
values.set(key, valueNode);
|
||||
}
|
||||
|
||||
const pathNode = values.get('path');
|
||||
const methodNode = values.get('method');
|
||||
const handlerNode = values.get('handler');
|
||||
if (pathNode === undefined || methodNode === undefined || handlerNode === undefined) return null;
|
||||
|
||||
const path = plainString(pathNode);
|
||||
const rawMethod = plainString(methodNode);
|
||||
const handler = handlerName(handlerNode);
|
||||
if (path === null || !path.startsWith('/') || rawMethod === null || handler === null) return null;
|
||||
const method = rawMethod.toUpperCase();
|
||||
if (!HTTP_METHODS.has(method)) return null;
|
||||
|
||||
return {
|
||||
path,
|
||||
method,
|
||||
handlerDesignator: handler.designator,
|
||||
handlerName: handler.name,
|
||||
...(handler.localName === undefined ? {} : { handlerLocalName: handler.localName }),
|
||||
line: node.startPosition.row + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function staticRouteIdentity(node: SyntaxNode): string | null {
|
||||
const values = new Map<string, SyntaxNode>();
|
||||
let lastUnknownProperty = -1;
|
||||
let lastPath = -1;
|
||||
let lastMethod = -1;
|
||||
for (const [index, child] of node.namedChildren.entries()) {
|
||||
if (child.type === 'comment') continue;
|
||||
if (child.type !== 'pair') {
|
||||
lastUnknownProperty = index;
|
||||
continue;
|
||||
}
|
||||
const keyNode = child.childForFieldName('key');
|
||||
const valueNode = child.childForFieldName('value');
|
||||
if (keyNode === null || valueNode === null) {
|
||||
lastUnknownProperty = index;
|
||||
continue;
|
||||
}
|
||||
const key = propertyName(keyNode);
|
||||
if (key === null) {
|
||||
lastUnknownProperty = index;
|
||||
continue;
|
||||
}
|
||||
if (!ROUTE_TOKEN_HINTS.includes(key as (typeof ROUTE_TOKEN_HINTS)[number])) continue;
|
||||
values.set(key, valueNode);
|
||||
if (key === 'path') lastPath = index;
|
||||
if (key === 'method') lastMethod = index;
|
||||
}
|
||||
const pathNode = values.get('path');
|
||||
const methodNode = values.get('method');
|
||||
if (pathNode === undefined || methodNode === undefined || !values.has('handler')) return null;
|
||||
if (lastPath < lastUnknownProperty || lastMethod < lastUnknownProperty) return null;
|
||||
const path = plainString(pathNode);
|
||||
const rawMethod = plainString(methodNode);
|
||||
if (path === null || !path.startsWith('/') || rawMethod === null) return null;
|
||||
const method = rawMethod.toUpperCase();
|
||||
return HTTP_METHODS.has(method) ? `${method}\0${path}` : null;
|
||||
}
|
||||
|
||||
function lexicalContainer(node: SyntaxNode): SyntaxNode {
|
||||
let current = node;
|
||||
while (current.parent !== null) {
|
||||
current = current.parent;
|
||||
if (current.type === 'program' || current.type === 'statement_block') return current;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function loopBindingName(node: SyntaxNode): string | null {
|
||||
const left = node.childForFieldName('left');
|
||||
if (left?.type === 'identifier') return left.text;
|
||||
if (left?.type !== 'lexical_declaration' && left?.type !== 'variable_declaration') return null;
|
||||
const declarator = left.namedChildren.find((child) => child.type === 'variable_declarator');
|
||||
const name = declarator?.childForFieldName('name');
|
||||
return name?.type === 'identifier' ? name.text : null;
|
||||
}
|
||||
|
||||
function entryMemberField(node: SyntaxNode, entryName: string): string | null {
|
||||
if (node.type === 'parenthesized_expression') {
|
||||
const nested = node.namedChildren[0];
|
||||
return nested === undefined ? null : entryMemberField(nested, entryName);
|
||||
}
|
||||
if (node.type !== 'member_expression') return null;
|
||||
const object = node.childForFieldName('object');
|
||||
const property = node.childForFieldName('property');
|
||||
return object?.type === 'identifier' &&
|
||||
object.text === entryName &&
|
||||
property?.type === 'property_identifier'
|
||||
? property.text
|
||||
: null;
|
||||
}
|
||||
|
||||
const NESTED_EXECUTABLES = new Set([
|
||||
'function_declaration',
|
||||
'function_expression',
|
||||
'generator_function_declaration',
|
||||
'generator_function',
|
||||
'arrow_function',
|
||||
'method_definition',
|
||||
'class_declaration',
|
||||
'class',
|
||||
]);
|
||||
|
||||
function referencesEntry(node: SyntaxNode, entryName: string): boolean {
|
||||
if (node.type === 'identifier' && node.text === entryName) return true;
|
||||
return node.namedChildren.some((child) => referencesEntry(child, entryName));
|
||||
}
|
||||
|
||||
const REQUEST_ROOTS = new Set(['req', 'request', 'event', 'ctx', 'context', 'url']);
|
||||
const REQUEST_PATH_FIELDS = new Set(['path', 'pathname', 'url', 'rawpath']);
|
||||
const REQUEST_METHOD_FIELDS = new Set(['method', 'httpmethod', 'verb']);
|
||||
|
||||
function isEnclosingParameter(node: SyntaxNode, name: string): boolean {
|
||||
let current: SyntaxNode | null = node.parent;
|
||||
while (current !== null) {
|
||||
if (NESTED_EXECUTABLES.has(current.type)) {
|
||||
const names = new Set<string>();
|
||||
collectPatternNames(current.childForFieldName('parameters'), (value) => names.add(value));
|
||||
collectPatternNames(current.childForFieldName('parameter'), (value) => names.add(value));
|
||||
return names.has(name);
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isRequestField(
|
||||
node: SyntaxNode,
|
||||
field: 'path' | 'method',
|
||||
bindingCounts: ReadonlyMap<string, number>,
|
||||
): boolean {
|
||||
if (node.type === 'parenthesized_expression') {
|
||||
const nested = node.namedChildren[0];
|
||||
return nested !== undefined && isRequestField(nested, field, bindingCounts);
|
||||
}
|
||||
const accepted = field === 'path' ? REQUEST_PATH_FIELDS : REQUEST_METHOD_FIELDS;
|
||||
if (node.type !== 'member_expression') return false;
|
||||
const object = node.childForFieldName('object');
|
||||
const property = node.childForFieldName('property');
|
||||
const root = object?.type === 'identifier' ? object.text : null;
|
||||
const bindingCount = root === null ? 0 : (bindingCounts.get(root) ?? 0);
|
||||
return (
|
||||
property?.type === 'property_identifier' &&
|
||||
accepted.has(property.text.toLowerCase()) &&
|
||||
root !== null &&
|
||||
REQUEST_ROOTS.has(root.toLowerCase()) &&
|
||||
(bindingCount === 0 || (bindingCount === 1 && isEnclosingParameter(node, root)))
|
||||
);
|
||||
}
|
||||
|
||||
interface ComparisonEvidence {
|
||||
valid: boolean;
|
||||
fields: Set<string>;
|
||||
}
|
||||
|
||||
function comparisonFields(
|
||||
condition: SyntaxNode,
|
||||
entryName: string,
|
||||
bindingCounts: ReadonlyMap<string, number>,
|
||||
): ComparisonEvidence {
|
||||
const text = condition.text.trim();
|
||||
if (text === 'false' || text === '0' || text === 'null' || text === 'undefined') {
|
||||
return { valid: false, fields: new Set() };
|
||||
}
|
||||
if (condition.type === 'parenthesized_expression') {
|
||||
const nested = condition.namedChildren[0];
|
||||
return nested === undefined
|
||||
? { valid: true, fields: new Set() }
|
||||
: comparisonFields(nested, entryName, bindingCounts);
|
||||
}
|
||||
if (condition.type === 'unary_expression') return { valid: false, fields: new Set() };
|
||||
if (condition.type !== 'binary_expression') return { valid: false, fields: new Set() };
|
||||
|
||||
const operator = condition.children.find((child) => !child.isNamed)?.type;
|
||||
const left = condition.childForFieldName('left');
|
||||
const right = condition.childForFieldName('right');
|
||||
if (left === null || right === null) return { valid: false, fields: new Set() };
|
||||
|
||||
if (operator === '||') return { valid: false, fields: new Set() };
|
||||
if (operator === '&&') {
|
||||
const leftEvidence = comparisonFields(left, entryName, bindingCounts);
|
||||
const rightEvidence = comparisonFields(right, entryName, bindingCounts);
|
||||
if (!leftEvidence.valid || !rightEvidence.valid) return { valid: false, fields: new Set() };
|
||||
return {
|
||||
valid: true,
|
||||
fields: new Set([...leftEvidence.fields, ...rightEvidence.fields]),
|
||||
};
|
||||
}
|
||||
if (operator !== '===' && operator !== '==') return { valid: false, fields: new Set() };
|
||||
|
||||
const leftField = entryMemberField(left, entryName);
|
||||
const rightField = entryMemberField(right, entryName);
|
||||
if ((leftField === null) === (rightField === null)) return { valid: false, fields: new Set() };
|
||||
if (
|
||||
(leftField !== null && referencesEntry(right, entryName)) ||
|
||||
(rightField !== null && referencesEntry(left, entryName))
|
||||
) {
|
||||
return { valid: false, fields: new Set() };
|
||||
}
|
||||
const field = leftField ?? rightField;
|
||||
const requestOperand = leftField !== null ? right : left;
|
||||
const matchesRequest =
|
||||
(field === 'path' || field === 'method') &&
|
||||
isRequestField(requestOperand, field, bindingCounts);
|
||||
return {
|
||||
valid: matchesRequest,
|
||||
fields: matchesRequest ? new Set([field]) : new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function directlyCallsHandler(node: SyntaxNode, entryName: string): boolean {
|
||||
let found = false;
|
||||
const visit = (child: SyntaxNode): void => {
|
||||
if (found || (child !== node && NESTED_EXECUTABLES.has(child.type))) return;
|
||||
if (child.type === 'statement_block') {
|
||||
for (const statement of child.namedChildren) {
|
||||
visit(statement);
|
||||
if (found || definitelyTerminates(statement)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (child.type === 'call_expression') {
|
||||
const fn = child.childForFieldName('function');
|
||||
if (fn !== null && entryMemberField(fn, entryName) === 'handler') {
|
||||
let current: SyntaxNode = child;
|
||||
let parent = current.parent;
|
||||
while (parent !== null && current.id !== node.id) {
|
||||
if (
|
||||
parent.type !== 'await_expression' &&
|
||||
parent.type !== 'parenthesized_expression' &&
|
||||
parent.type !== 'expression_statement' &&
|
||||
parent.type !== 'return_statement' &&
|
||||
parent.type !== 'statement_block'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
current = parent;
|
||||
parent = current.parent;
|
||||
}
|
||||
found = current.id === node.id;
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const nested of child.namedChildren) visit(nested);
|
||||
};
|
||||
visit(node);
|
||||
return found;
|
||||
}
|
||||
|
||||
function isWriteTarget(node: SyntaxNode): boolean {
|
||||
let current: SyntaxNode = node;
|
||||
let parent = current.parent;
|
||||
while (parent !== null) {
|
||||
if (
|
||||
parent.type === 'assignment_expression' ||
|
||||
parent.type === 'augmented_assignment_expression'
|
||||
) {
|
||||
const left = parent.childForFieldName('left');
|
||||
return left !== null && left.startIndex <= node.startIndex && left.endIndex >= node.endIndex;
|
||||
}
|
||||
if (parent.type === 'update_expression') return true;
|
||||
if (parent.type === 'unary_expression' && parent.text.trimStart().startsWith('delete ')) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
parent.type === 'expression_statement' ||
|
||||
parent.type === 'variable_declarator' ||
|
||||
parent.type === 'call_expression'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
current = parent;
|
||||
parent = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasOnlyIngressReferences(scope: SyntaxNode): boolean {
|
||||
let valid = true;
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
if (!valid) return;
|
||||
if (node.type === 'identifier' && REQUEST_ROOTS.has(node.text.toLowerCase())) {
|
||||
const member = node.parent;
|
||||
const property = member?.childForFieldName('property');
|
||||
if (
|
||||
member?.type !== 'member_expression' ||
|
||||
member.childForFieldName('object')?.id !== node.id ||
|
||||
property?.type !== 'property_identifier' ||
|
||||
(!REQUEST_PATH_FIELDS.has(property.text.toLowerCase()) &&
|
||||
!REQUEST_METHOD_FIELDS.has(property.text.toLowerCase())) ||
|
||||
isWriteTarget(member)
|
||||
) {
|
||||
valid = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const child of node.namedChildren) visit(child);
|
||||
};
|
||||
visit(scope);
|
||||
return valid;
|
||||
}
|
||||
|
||||
function hasOnlyDispatchEntryReferences(node: SyntaxNode, entryName: string): boolean {
|
||||
let valid = true;
|
||||
const visit = (child: SyntaxNode): void => {
|
||||
if (!valid) return;
|
||||
if (child.type === 'identifier' && child.text === entryName) {
|
||||
const member = child.parent;
|
||||
if (
|
||||
member?.type !== 'member_expression' ||
|
||||
member.childForFieldName('object')?.id !== child.id
|
||||
) {
|
||||
valid = false;
|
||||
return;
|
||||
}
|
||||
const property = member.childForFieldName('property');
|
||||
if (property?.type !== 'property_identifier' || isWriteTarget(member)) {
|
||||
valid = false;
|
||||
return;
|
||||
}
|
||||
if (property.text === 'handler') {
|
||||
const call = member.parent;
|
||||
if (
|
||||
call?.type !== 'call_expression' ||
|
||||
call.childForFieldName('function')?.id !== member.id
|
||||
) {
|
||||
valid = false;
|
||||
return;
|
||||
}
|
||||
} else if (property.text !== 'path' && property.text !== 'method') {
|
||||
valid = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const nested of child.namedChildren) visit(nested);
|
||||
};
|
||||
visit(node);
|
||||
return valid;
|
||||
}
|
||||
|
||||
function hasProviderDispatch(
|
||||
body: SyntaxNode,
|
||||
entryName: string,
|
||||
bindingCounts: ReadonlyMap<string, number>,
|
||||
): boolean {
|
||||
if (!hasOnlyDispatchEntryReferences(body, entryName)) return false;
|
||||
let found = false;
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
if (found || (node !== body && NESTED_EXECUTABLES.has(node.type))) return;
|
||||
if (node.type === 'statement_block') {
|
||||
for (const statement of node.namedChildren) {
|
||||
visit(statement);
|
||||
if (found || definitelyTerminates(statement)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.type === 'if_statement') {
|
||||
const condition = node.childForFieldName('condition');
|
||||
const consequence = node.childForFieldName('consequence');
|
||||
if (condition !== null && isStaticallyFalse(condition)) {
|
||||
const alternative = node.childForFieldName('alternative');
|
||||
if (alternative !== null) visit(alternative);
|
||||
return;
|
||||
}
|
||||
if (condition !== null && consequence !== null) {
|
||||
const evidence = comparisonFields(condition, entryName, bindingCounts);
|
||||
if (
|
||||
evidence.valid &&
|
||||
evidence.fields.has('path') &&
|
||||
evidence.fields.has('method') &&
|
||||
directlyCallsHandler(consequence, entryName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of node.namedChildren) visit(child);
|
||||
};
|
||||
visit(body);
|
||||
return found;
|
||||
}
|
||||
|
||||
function isStaticallyFalse(node: SyntaxNode): boolean {
|
||||
if (node.type === 'parenthesized_expression') {
|
||||
const nested = node.namedChildren[0];
|
||||
return nested !== undefined && isStaticallyFalse(nested);
|
||||
}
|
||||
const text = node.text.trim();
|
||||
return text === 'false' || text === '0' || text === 'null' || text === 'undefined';
|
||||
}
|
||||
|
||||
function isStaticallyTrue(node: SyntaxNode): boolean {
|
||||
if (node.type === 'parenthesized_expression') {
|
||||
const nested = node.namedChildren[0];
|
||||
return nested !== undefined && isStaticallyTrue(nested);
|
||||
}
|
||||
return node.text.trim() === 'true';
|
||||
}
|
||||
|
||||
function definitelyTerminates(node: SyntaxNode): boolean {
|
||||
if (
|
||||
node.type === 'return_statement' ||
|
||||
node.type === 'throw_statement' ||
|
||||
node.type === 'break_statement' ||
|
||||
node.type === 'continue_statement'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (node.type === 'statement_block') {
|
||||
return node.namedChildren.some((statement) => definitelyTerminates(statement));
|
||||
}
|
||||
if (node.type !== 'if_statement') return false;
|
||||
const condition = node.childForFieldName('condition');
|
||||
const consequence = node.childForFieldName('consequence');
|
||||
const alternative = node.childForFieldName('alternative');
|
||||
if (condition === null) return false;
|
||||
if (isStaticallyTrue(condition)) {
|
||||
return consequence !== null && definitelyTerminates(consequence);
|
||||
}
|
||||
if (isStaticallyFalse(condition)) {
|
||||
return alternative !== null && definitelyTerminates(alternative);
|
||||
}
|
||||
return (
|
||||
consequence !== null &&
|
||||
alternative !== null &&
|
||||
definitelyTerminates(consequence) &&
|
||||
definitelyTerminates(alternative)
|
||||
);
|
||||
}
|
||||
|
||||
function hasDispatchLoop(
|
||||
scope: SyntaxNode,
|
||||
tableName: string,
|
||||
bindingCounts: ReadonlyMap<string, number>,
|
||||
): boolean {
|
||||
let found = false;
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
if (found) return;
|
||||
if (node !== scope && NESTED_EXECUTABLES.has(node.type)) return;
|
||||
if (node.type === 'for_in_statement') {
|
||||
const right = node.childForFieldName('right');
|
||||
const isForOf = node.children.some((child) => child.type === 'of');
|
||||
const hasConstBinding = node.children.some((child) => child.type === 'const');
|
||||
if (isForOf && hasConstBinding && right?.type === 'identifier' && right.text === tableName) {
|
||||
const entryName = loopBindingName(node);
|
||||
const body = node.childForFieldName('body');
|
||||
if (
|
||||
entryName !== null &&
|
||||
bindingCounts.get(entryName) === 1 &&
|
||||
body !== null &&
|
||||
hasProviderDispatch(body, entryName, bindingCounts)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of node.namedChildren) visit(child);
|
||||
};
|
||||
visit(scope);
|
||||
return found;
|
||||
}
|
||||
|
||||
function collectPatternNames(node: SyntaxNode | null, add: (name: string) => void): void {
|
||||
if (node === null) return;
|
||||
if (node.type === 'identifier') {
|
||||
add(node.text);
|
||||
return;
|
||||
}
|
||||
for (const child of node.namedChildren) collectPatternNames(child, add);
|
||||
}
|
||||
|
||||
function collectBindingCounts(root: SyntaxNode): ReadonlyMap<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
const add = (name: string): void => {
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
};
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
if (node.type === 'import_statement') {
|
||||
const clause = node.namedChildren.find((child) => child.type === 'import_clause');
|
||||
collectPatternNames(clause ?? null, add);
|
||||
return;
|
||||
}
|
||||
if (node.type === 'variable_declarator') {
|
||||
collectPatternNames(node.childForFieldName('name'), add);
|
||||
} else if (node.type === 'for_in_statement') {
|
||||
const left = node.childForFieldName('left');
|
||||
if (left?.type === 'identifier') add(left.text);
|
||||
} else if (
|
||||
node.type === 'function_declaration' ||
|
||||
node.type === 'generator_function_declaration' ||
|
||||
node.type === 'class_declaration'
|
||||
) {
|
||||
collectPatternNames(node.childForFieldName('name'), add);
|
||||
}
|
||||
if (
|
||||
node.type === 'function_declaration' ||
|
||||
node.type === 'function_expression' ||
|
||||
node.type === 'generator_function_declaration' ||
|
||||
node.type === 'generator_function' ||
|
||||
node.type === 'arrow_function' ||
|
||||
node.type === 'method_definition'
|
||||
) {
|
||||
collectPatternNames(node.childForFieldName('parameters'), add);
|
||||
collectPatternNames(node.childForFieldName('parameter'), add);
|
||||
} else if (node.type === 'catch_clause') {
|
||||
collectPatternNames(node.childForFieldName('parameter'), add);
|
||||
}
|
||||
for (const child of node.namedChildren) visit(child);
|
||||
};
|
||||
visit(root);
|
||||
return counts;
|
||||
}
|
||||
|
||||
function isConstDeclarator(node: SyntaxNode): boolean {
|
||||
const declaration = node.parent;
|
||||
return (
|
||||
declaration?.type === 'lexical_declaration' &&
|
||||
declaration.children.some((child) => child.type === 'const')
|
||||
);
|
||||
}
|
||||
|
||||
function hasOnlyDispatchReferences(
|
||||
scope: SyntaxNode,
|
||||
declarator: SyntaxNode,
|
||||
tableName: string,
|
||||
): boolean {
|
||||
const declarationName = declarator.childForFieldName('name');
|
||||
let valid = true;
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
if (!valid) return;
|
||||
if (node.type === 'identifier' && node.text === tableName) {
|
||||
if (declarationName?.id === node.id) return;
|
||||
const parent = node.parent;
|
||||
const allowedForOf =
|
||||
parent?.type === 'for_in_statement' &&
|
||||
parent.childForFieldName('right')?.id === node.id &&
|
||||
parent.children.some((child) => child.type === 'of');
|
||||
const lengthMember =
|
||||
parent?.type === 'member_expression' &&
|
||||
parent.childForFieldName('object')?.id === node.id &&
|
||||
parent.childForFieldName('property')?.text === 'length'
|
||||
? parent
|
||||
: null;
|
||||
const allowedLengthRead = lengthMember !== null && !isWriteTarget(lengthMember);
|
||||
if (!allowedForOf && !allowedLengthRead) {
|
||||
valid = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const child of node.namedChildren) visit(child);
|
||||
};
|
||||
visit(scope);
|
||||
return valid;
|
||||
}
|
||||
|
||||
type HandlerReferenceIndex = ReadonlyMap<string, readonly SyntaxNode[]>;
|
||||
|
||||
function collectHandlerReferences(root: SyntaxNode): HandlerReferenceIndex {
|
||||
const references = new Map<string, SyntaxNode[]>();
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
if (
|
||||
node.type === 'identifier' ||
|
||||
node.type === 'shorthand_property_identifier' ||
|
||||
node.type === 'shorthand_property_identifier_pattern'
|
||||
) {
|
||||
const matches = references.get(node.text);
|
||||
if (matches === undefined) references.set(node.text, [node]);
|
||||
else matches.push(node);
|
||||
}
|
||||
for (const child of node.namedChildren) visit(child);
|
||||
};
|
||||
visit(root);
|
||||
return references;
|
||||
}
|
||||
|
||||
function containsNode(container: SyntaxNode | null, node: SyntaxNode): boolean {
|
||||
return (
|
||||
container !== null &&
|
||||
container.startIndex <= node.startIndex &&
|
||||
container.endIndex >= node.endIndex
|
||||
);
|
||||
}
|
||||
|
||||
function isUnsafeHandlerReference(
|
||||
reference: SyntaxNode,
|
||||
handlerRoot: string,
|
||||
routeEntry: SyntaxNode,
|
||||
): boolean {
|
||||
let current: SyntaxNode = reference;
|
||||
let parent = current.parent;
|
||||
while (parent !== null) {
|
||||
if (parent.type === 'variable_declarator') {
|
||||
const name = parent.childForFieldName('name');
|
||||
const value = parent.childForFieldName('value');
|
||||
if (
|
||||
name?.type === 'identifier' &&
|
||||
name.text !== handlerRoot &&
|
||||
containsNode(value, reference) &&
|
||||
!isRouteHandlerDesignatorReference(reference, routeEntry)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// The remaining checks historically matched ordinary identifiers only.
|
||||
if (reference.type === 'identifier') {
|
||||
if (
|
||||
parent.type === 'assignment_expression' ||
|
||||
parent.type === 'augmented_assignment_expression' ||
|
||||
parent.type === 'update_expression' ||
|
||||
(parent.type === 'unary_expression' && parent.text.trimStart().startsWith('delete ')) ||
|
||||
parent.type === 'return_statement' ||
|
||||
parent.type === 'yield_expression' ||
|
||||
parent.type === 'throw_statement'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (parent.type === 'call_expression') {
|
||||
const args = parent.childForFieldName('arguments');
|
||||
const fn = parent.childForFieldName('function');
|
||||
const owner = fn?.childForFieldName('object');
|
||||
const callsOwnerMember =
|
||||
(fn?.type === 'member_expression' || fn?.type === 'subscript_expression') &&
|
||||
identifierName(owner) === handlerRoot &&
|
||||
containsNode(owner ?? null, reference);
|
||||
if (containsNode(args, reference) || callsOwnerMember) return true;
|
||||
}
|
||||
}
|
||||
|
||||
current = parent;
|
||||
parent = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasStableHandlerBinding(
|
||||
references: HandlerReferenceIndex,
|
||||
handlerRoot: string,
|
||||
routeEntry: SyntaxNode,
|
||||
): boolean {
|
||||
return !(references.get(handlerRoot) ?? []).some((reference) =>
|
||||
isUnsafeHandlerReference(reference, handlerRoot, routeEntry),
|
||||
);
|
||||
}
|
||||
|
||||
function identifierName(node: SyntaxNode | null | undefined): string | null {
|
||||
if (node?.type === 'identifier') return node.text;
|
||||
if (node?.type === 'parenthesized_expression') {
|
||||
return identifierName(node.namedChildren[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRouteHandlerDesignatorReference(node: SyntaxNode, routeEntry: SyntaxNode): boolean {
|
||||
let designator = node;
|
||||
if (
|
||||
node.parent?.type === 'member_expression' &&
|
||||
node.parent.childForFieldName('object')?.id === node.id
|
||||
) {
|
||||
designator = node.parent;
|
||||
}
|
||||
const pair = designator.parent;
|
||||
return (
|
||||
pair?.type === 'pair' &&
|
||||
pair.parent?.id === routeEntry.id &&
|
||||
pair.childForFieldName('value')?.id === designator.id &&
|
||||
propertyName(pair.childForFieldName('key')) === 'handler'
|
||||
);
|
||||
}
|
||||
|
||||
/** Return static route facts shared by ingestion and group contract extraction. */
|
||||
export function scanDataRouteTables(tree: Parser.Tree): DataRouteTableRoute[] {
|
||||
const source = tree.rootNode.text;
|
||||
if (!ROUTE_TOKEN_HINTS.every((token) => source.includes(token))) return [];
|
||||
|
||||
const routes: DataRouteTableRoute[] = [];
|
||||
const blockedIdentities = new Set<string>();
|
||||
let hasUnknownTableEntries = false;
|
||||
const bindingCounts = collectBindingCounts(tree.rootNode);
|
||||
const handlerReferences = collectHandlerReferences(tree.rootNode);
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
if (node.type === 'variable_declarator') {
|
||||
const name = node.childForFieldName('name');
|
||||
const value = node.childForFieldName('value');
|
||||
if (
|
||||
name?.type === 'identifier' &&
|
||||
ROUTE_BINDING_HINT.test(name.text) &&
|
||||
bindingCounts.get(name.text) === 1 &&
|
||||
isConstDeclarator(node) &&
|
||||
value?.type === 'array' &&
|
||||
hasOnlyDispatchReferences(lexicalContainer(node), node, name.text) &&
|
||||
hasOnlyIngressReferences(lexicalContainer(node)) &&
|
||||
hasDispatchLoop(lexicalContainer(node), name.text, bindingCounts)
|
||||
) {
|
||||
const parsedRoutes: DataRouteTableRoute[] = [];
|
||||
let blocksFollowingCandidates = false;
|
||||
for (const entry of value.namedChildren) {
|
||||
if (entry.type === 'comment') continue;
|
||||
if (entry.type !== 'object') {
|
||||
hasUnknownTableEntries = true;
|
||||
continue;
|
||||
}
|
||||
const route = routeFromObject(entry);
|
||||
if (route === null) {
|
||||
const identity = staticRouteIdentity(entry);
|
||||
if (identity !== null) blockedIdentities.add(identity);
|
||||
if (identity === null) {
|
||||
blocksFollowingCandidates = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const identity = `${route.method}\0${route.path}`;
|
||||
if (blocksFollowingCandidates) {
|
||||
blockedIdentities.add(identity);
|
||||
continue;
|
||||
}
|
||||
const handlerRoot = route.handlerDesignator.split('.')[0];
|
||||
if (
|
||||
handlerRoot === undefined ||
|
||||
(bindingCounts.get(handlerRoot) ?? 0) > 1 ||
|
||||
!hasStableHandlerBinding(handlerReferences, handlerRoot, entry)
|
||||
) {
|
||||
blockedIdentities.add(identity);
|
||||
continue;
|
||||
}
|
||||
parsedRoutes.push(route);
|
||||
}
|
||||
routes.push(...parsedRoutes);
|
||||
}
|
||||
}
|
||||
for (const child of node.namedChildren) visit(child);
|
||||
};
|
||||
visit(tree.rootNode);
|
||||
if (hasUnknownTableEntries) return [];
|
||||
return routes.filter((route) => !blockedIdentities.has(`${route.method}\0${route.path}`));
|
||||
}
|
||||
|
||||
export function extractDataRouteTableRoutes(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
lineOffset = 0,
|
||||
): ExtractedDecoratorRoute[] {
|
||||
return scanDataRouteTables(tree).map((route) => ({
|
||||
filePath,
|
||||
routePath: route.path,
|
||||
httpMethod: route.method,
|
||||
decoratorName: 'DataRouteTable',
|
||||
lineNumber: route.line + lineOffset,
|
||||
// This source preserves a static dotted designator in the existing field.
|
||||
// Its resolver recognizes the source tag and proves the receiver owner;
|
||||
// ordinary decorator routes continue to carry a simple handler name.
|
||||
handlerName: route.handlerDesignator,
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
}));
|
||||
}
|
||||
|
|
@ -534,13 +534,10 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// stale 2), which is the rule above: above every claim, not above origin/main.
|
||||
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
|
||||
//
|
||||
// 68 -> 70 for Spring non-HTTP handler side-channel facts (#2417 / #2891).
|
||||
// Java and Kotlin ParsedFiles now persist scheduled, event, messaging, and
|
||||
// managed-job handler syntax. A warm cache without these facts would stamp the
|
||||
// analysis feature as complete while promoting zero handlers. This PR's former
|
||||
// value 59 is now part of main's ledger, main currently holds 68, and open PR
|
||||
// #2972 publishes 69; 70 is the next free value above every known claim.
|
||||
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
|
||||
// 68 -> 69 added #2969's JS/TS data-route-table decoratorRoutes. A warm v68
|
||||
// cache would replay unchanged worker results without those routes. Version 70
|
||||
// then adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so Java
|
||||
// and Kotlin caches persist scheduled, event, messaging, and managed-job facts.
|
||||
const SCHEMA_BUMP = 70;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
|
|
|
|||
3
gitnexus/test/fixtures/data-route-table-app/decoy.js
vendored
Normal file
3
gitnexus/test/fixtures/data-route-table-app/decoy.js
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function handleUsers() {
|
||||
return { wrong: true };
|
||||
}
|
||||
13
gitnexus/test/fixtures/data-route-table-app/handlers.js
vendored
Normal file
13
gitnexus/test/fixtures/data-route-table-app/handlers.js
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export function listUsers(_req, res) {
|
||||
return res.json({ users: [] });
|
||||
}
|
||||
|
||||
export function createUser(_req, res) {
|
||||
return res.json({ createdId: 'new' });
|
||||
}
|
||||
|
||||
export const auth = {
|
||||
getCurrentUser(_req, res) {
|
||||
return res.json({ accountId: 'current' });
|
||||
},
|
||||
};
|
||||
23
gitnexus/test/fixtures/data-route-table-app/routes.js
vendored
Normal file
23
gitnexus/test/fixtures/data-route-table-app/routes.js
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { auth, createUser, listUsers as handleUsers } from './handlers.js';
|
||||
|
||||
const runtimePath = '/dynamic';
|
||||
const baseRoute = { path: '/spread', method: 'GET', handler: spreadHandler };
|
||||
|
||||
const apiRoutes = [
|
||||
{ path: '/users', method: 'GET', handler: handleUsers },
|
||||
{ path: '/users', method: 'POST', handler: createUser },
|
||||
{ path: '/auth/me', method: 'GET', handler: auth.getCurrentUser, auth: true },
|
||||
{ path: runtimePath, method: 'GET', handler: dynamicHandler },
|
||||
{ ...baseRoute },
|
||||
{ ['path']: '/computed', method: 'GET', handler: computedHandler },
|
||||
{ path: '/inline', method: 'GET', handler: () => null },
|
||||
{ path: '/unresolved', method: 'GET', handler: missing.handler },
|
||||
];
|
||||
|
||||
for (const route of apiRoutes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
|
||||
export function routeCount() {
|
||||
return apiRoutes.length;
|
||||
}
|
||||
68
gitnexus/test/integration/data-route-table-benchmark.test.ts
Normal file
68
gitnexus/test/integration/data-route-table-benchmark.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* JavaScript data-route-table handler-stability scaling benchmark.
|
||||
*
|
||||
* The scanner used to walk the complete AST once for every route handler,
|
||||
* making extraction quadratic as a route table grew. This benchmark parses
|
||||
* outside the timed region and co-scales unique handlers and route entries so
|
||||
* the measured work isolates scanDataRouteTables.
|
||||
*
|
||||
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/data-route-table-benchmark.test.ts
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import JavaScript from 'tree-sitter-javascript';
|
||||
import { scanDataRouteTables } from '../../src/core/ingestion/route-extractors/data-route-table.js';
|
||||
|
||||
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(JavaScript);
|
||||
|
||||
interface BenchResult {
|
||||
routes: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
function fixture(routeCount: number): string {
|
||||
const handlers = Array.from({ length: routeCount }, (_, i) => `function handler${i}() {}`).join(
|
||||
'\n',
|
||||
);
|
||||
const entries = Array.from(
|
||||
{ length: routeCount },
|
||||
(_, i) => ` { path: '/route-${i}', method: 'GET', handler: handler${i} },`,
|
||||
).join('\n');
|
||||
return `${handlers}
|
||||
const routes = [
|
||||
${entries}
|
||||
];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}`;
|
||||
}
|
||||
|
||||
function benchmark(routeCount: number): BenchResult {
|
||||
const tree = parser.parse(fixture(routeCount));
|
||||
const started = performance.now();
|
||||
const routes = scanDataRouteTables(tree);
|
||||
const elapsedMs = performance.now() - started;
|
||||
expect(routes).toHaveLength(routeCount);
|
||||
return { routes: routeCount, elapsedMs };
|
||||
}
|
||||
|
||||
describe.skipIf(!BENCH_ENABLED)('data-route-table scanner benchmark', () => {
|
||||
it('scales sub-quadratically as handlers and route entries grow together', () => {
|
||||
benchmark(10);
|
||||
|
||||
const small = benchmark(50);
|
||||
const large = benchmark(200);
|
||||
const routeRatio = large.routes / small.routes;
|
||||
|
||||
console.log('\nJavaScript data-route-table scanner benchmark');
|
||||
console.log(` routes=${small.routes} wall=${small.elapsedMs.toFixed(2)}ms`);
|
||||
console.log(` routes=${large.routes} wall=${large.elapsedMs.toFixed(2)}ms`);
|
||||
|
||||
if (small.elapsedMs >= 5) {
|
||||
expect(large.elapsedMs / small.elapsedMs).toBeLessThan(Math.pow(routeRatio, 1.5));
|
||||
}
|
||||
expect(large.elapsedMs).toBeLessThan(5_000);
|
||||
}, 120_000);
|
||||
});
|
||||
263
gitnexus/test/integration/data-route-table-pipeline.test.ts
Normal file
263
gitnexus/test/integration/data-route-table-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineResult } from '../../types/pipeline.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from '../../src/core/ingestion/route-extractors/data-route-table.js';
|
||||
import {
|
||||
loadParseCache,
|
||||
PARSE_CACHE_VERSION,
|
||||
pruneCache,
|
||||
saveParseCache,
|
||||
type ParseCache,
|
||||
} from '../../src/storage/parse-cache.js';
|
||||
import {
|
||||
getDurableParsedFileDir,
|
||||
pruneAndSaveDurableParsedFileStore,
|
||||
} from '../../src/storage/parsedfile-store.js';
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'data-route-table-app');
|
||||
|
||||
describe('data-driven route table ingestion', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(FIXTURE, () => {}, {});
|
||||
}, 60_000);
|
||||
|
||||
const routes = (pipeline: PipelineResult = result) => {
|
||||
const found: Array<{
|
||||
path: string;
|
||||
method?: string;
|
||||
handler?: string;
|
||||
filePath: string;
|
||||
responseKeys?: string[];
|
||||
}> = [];
|
||||
pipeline.graph.forEachNode((node) => {
|
||||
if (node.label !== 'Route') return;
|
||||
found.push({
|
||||
path: String(node.properties.name),
|
||||
method: node.properties.method as string | undefined,
|
||||
handler: node.properties.handlerSymbolId as string | undefined,
|
||||
filePath: String(node.properties.filePath),
|
||||
responseKeys: node.properties.responseKeys as string[] | undefined,
|
||||
});
|
||||
});
|
||||
return found;
|
||||
};
|
||||
|
||||
it('emits distinct GET and POST Route nodes for the same URL', () => {
|
||||
expect(
|
||||
routes()
|
||||
.map((route) => `${route.method} ${route.path}`)
|
||||
.sort(),
|
||||
).toEqual(['GET /auth/me', 'GET /users', 'POST /users']);
|
||||
});
|
||||
|
||||
it('resolves free and member handlers across files', () => {
|
||||
expect(
|
||||
routes().find((route) => route.path === '/users' && route.method === 'GET')?.handler,
|
||||
).toMatch(/listUsers/);
|
||||
expect(
|
||||
routes().find((route) => route.path === '/users' && route.method === 'GET')?.handler,
|
||||
).not.toMatch(/handleUsers/);
|
||||
expect(
|
||||
routes().find((route) => route.path === '/users' && route.method === 'POST')?.handler,
|
||||
).toMatch(/createUser/);
|
||||
expect(routes().find((route) => route.path === '/auth/me')?.handler).toMatch(/getCurrentUser/);
|
||||
});
|
||||
|
||||
it('links every resolved route from the real cross-file handler file', () => {
|
||||
const handled: Array<{ filePath: string; method?: string; path: string; reason: string }> = [];
|
||||
result.graph.forEachRelationship((rel) => {
|
||||
if (rel.type !== 'HANDLES_ROUTE' || rel.reason !== DATA_ROUTE_TABLE_SOURCE) return;
|
||||
const source = result.graph.getNode(rel.sourceId);
|
||||
const target = result.graph.getNode(rel.targetId);
|
||||
if (source === undefined || target === undefined) return;
|
||||
handled.push({
|
||||
filePath: String(source.properties.filePath),
|
||||
method: target.properties.method as string | undefined,
|
||||
path: String(target.properties.name),
|
||||
reason: String(rel.reason),
|
||||
});
|
||||
});
|
||||
expect(
|
||||
handled.sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`)),
|
||||
).toEqual([
|
||||
{
|
||||
filePath: 'handlers.js',
|
||||
method: 'GET',
|
||||
path: '/auth/me',
|
||||
reason: DATA_ROUTE_TABLE_SOURCE,
|
||||
},
|
||||
{
|
||||
filePath: 'handlers.js',
|
||||
method: 'GET',
|
||||
path: '/users',
|
||||
reason: DATA_ROUTE_TABLE_SOURCE,
|
||||
},
|
||||
{
|
||||
filePath: 'handlers.js',
|
||||
method: 'POST',
|
||||
path: '/users',
|
||||
reason: DATA_ROUTE_TABLE_SOURCE,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts response shapes from each resolved handler only', () => {
|
||||
const getUsers = routes().find((route) => route.path === '/users' && route.method === 'GET');
|
||||
const postUsers = routes().find((route) => route.path === '/users' && route.method === 'POST');
|
||||
const currentUser = routes().find((route) => route.path === '/auth/me');
|
||||
|
||||
expect(getUsers).toMatchObject({ filePath: 'handlers.js', responseKeys: ['users'] });
|
||||
expect(postUsers).toMatchObject({ filePath: 'handlers.js', responseKeys: ['createdId'] });
|
||||
expect(currentUser).toMatchObject({ filePath: 'handlers.js', responseKeys: ['accountId'] });
|
||||
});
|
||||
|
||||
it('does not emit dynamic, spread, computed, or inline-handler entries', () => {
|
||||
const paths = routes().map((route) => route.path);
|
||||
expect(paths).not.toContain('/dynamic');
|
||||
expect(paths).not.toContain('/spread');
|
||||
expect(paths).not.toContain('/computed');
|
||||
expect(paths).not.toContain('/inline');
|
||||
expect(paths).not.toContain('/unresolved');
|
||||
});
|
||||
|
||||
it('resolves declared jsconfig path aliases for handlers', async () => {
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-alias-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(repoDir, 'src', 'handlers'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'jsconfig.json'),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
baseUrl: '.',
|
||||
paths: { '@handler': ['src/handlers/user'] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'src', 'routes.js'),
|
||||
`import { aliasHandler } from '@handler';
|
||||
const routes = [{ path: '/alias', method: 'GET', handler: aliasHandler }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'src', 'handlers', 'user.js'),
|
||||
`export function aliasHandler(_req, res) { return res.json({ id: 'alias' }); }`,
|
||||
);
|
||||
const aliasResult = await runPipelineFromRepo(repoDir, () => {}, {});
|
||||
expect(routes(aliasResult)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: '/alias',
|
||||
method: 'GET',
|
||||
filePath: 'src/handlers/user.js',
|
||||
responseKeys: ['id'],
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('does not infer a default-export handler without explicit provenance', async () => {
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-default-'));
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'routes.js'),
|
||||
`import handleDefault from './handler.js';
|
||||
const routes = [{ path: '/default', method: 'GET', handler: handleDefault }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'handler.js'),
|
||||
`export default function defaultHandler(_req, res) {
|
||||
return res.json({ source: 'default' });
|
||||
}`,
|
||||
);
|
||||
|
||||
const defaultResult = await runPipelineFromRepo(repoDir, () => {}, {});
|
||||
expect(routes(defaultResult)).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('replays data-table routes from a serialized warm parse cache', async () => {
|
||||
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-warm-'));
|
||||
try {
|
||||
const cold: ParseCache = {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map(),
|
||||
usedKeys: new Set<string>(),
|
||||
storagePath: storageDir,
|
||||
onDiskKeys: new Set<string>(),
|
||||
};
|
||||
const coldResult = await runPipelineFromRepo(FIXTURE, () => {}, {
|
||||
parseCache: cold,
|
||||
workerPoolSize: 1,
|
||||
});
|
||||
expect(coldResult.usedWorkerPool).toBe(true);
|
||||
|
||||
pruneCache(cold, cold.usedKeys);
|
||||
const savedKeys = await saveParseCache(storageDir, cold);
|
||||
await pruneAndSaveDurableParsedFileStore(
|
||||
getDurableParsedFileDir(storageDir),
|
||||
PARSE_CACHE_VERSION,
|
||||
new Set(savedKeys),
|
||||
);
|
||||
const warm = await loadParseCache(storageDir);
|
||||
expect(warm).not.toBeNull();
|
||||
|
||||
const replay = await runPipelineFromRepo(FIXTURE, () => {}, {
|
||||
parseCache: warm ?? undefined,
|
||||
workerPoolSize: 1,
|
||||
});
|
||||
expect(replay.usedWorkerPool).toBe(false);
|
||||
|
||||
const project = (pipeline: PipelineResult) =>
|
||||
routes(pipeline)
|
||||
.map((route) => ({
|
||||
method: route.method,
|
||||
path: route.path,
|
||||
handler: route.handler,
|
||||
filePath: route.filePath,
|
||||
responseKeys: route.responseKeys,
|
||||
}))
|
||||
.sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`));
|
||||
const coldProjection = project(coldResult);
|
||||
expect(coldProjection).toEqual([
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/auth/me',
|
||||
handler: expect.stringMatching(/getCurrentUser/),
|
||||
filePath: 'handlers.js',
|
||||
responseKeys: ['accountId'],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/users',
|
||||
handler: expect.stringMatching(/listUsers/),
|
||||
filePath: 'handlers.js',
|
||||
responseKeys: ['users'],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/users',
|
||||
handler: expect.stringMatching(/createUser/),
|
||||
filePath: 'handlers.js',
|
||||
responseKeys: ['createdId'],
|
||||
},
|
||||
]);
|
||||
expect(project(replay)).toEqual(coldProjection);
|
||||
} finally {
|
||||
fs.rmSync(storageDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
import { it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
RESOLVE_BY_NAME_QUERY,
|
||||
RESOLVE_IN_MODULE_QUERY,
|
||||
RESOLVE_IN_EXACT_MODULE_QUERY,
|
||||
} from '../../../src/core/group/extractors/http-route-extractor.js';
|
||||
import { initLbug, executeParameterized, closeLbug } from '../../../src/core/lbug/pool-adapter.js';
|
||||
import { withTestLbugDB } from '../../helpers/test-indexed-db.js';
|
||||
|
|
@ -33,6 +33,7 @@ const SEED = [
|
|||
`CREATE (:CodeElement {id:'ce:getOrders', name:'getOrders', filePath:''})`,
|
||||
// IN_MODULE: same name in two modules — only the prefixed one resolves.
|
||||
`CREATE (:Function {id:'fn:listUsers:handlers', name:'listUsers', filePath:'src/handlers/users.ts', startLine:1, endLine:9, content:'', description:''})`,
|
||||
`CREATE (:Function {id:'fn:listUsers:test-decoy', name:'listUsers', filePath:'src/handlers/users.test.ts', startLine:1, endLine:9, content:'', description:''})`,
|
||||
`CREATE (:Function {id:'fn:listUsers:admin', name:'listUsers', filePath:'src/admin/users.ts', startLine:1, endLine:9, content:'', description:''})`,
|
||||
// IN_MODULE label-allowlist decoy: same name, SAME module prefix, wrong label.
|
||||
// The STARTS-WITH prefix would match it, so only the `labels(n) IN [...]` filter
|
||||
|
|
@ -66,12 +67,11 @@ withTestLbugDB(
|
|||
expect(rows[0].filePath).toBe('src/handlers/orders.ts');
|
||||
});
|
||||
|
||||
it('RESOLVE_IN_MODULE_QUERY resolves only the handler in the target module prefix', async () => {
|
||||
it('RESOLVE_IN_EXACT_MODULE_QUERY excludes sibling module stems', async () => {
|
||||
await initLbug(handle.repoId, handle.dbPath);
|
||||
const rows = await executeParameterized(handle.repoId, RESOLVE_IN_MODULE_QUERY, {
|
||||
const rows = await executeParameterized(handle.repoId, RESOLVE_IN_EXACT_MODULE_QUERY, {
|
||||
name: 'listUsers',
|
||||
fileDot: 'src/handlers/users.',
|
||||
fileSlash: 'src/handlers/users/',
|
||||
filePaths: ['src/handlers/users.ts', 'src/handlers/users/index.ts'],
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].uid).toBe('fn:listUsers:handlers');
|
||||
|
|
|
|||
819
gitnexus/test/unit/data-route-table-routes.test.ts
Normal file
819
gitnexus/test/unit/data-route-table-routes.test.ts
Normal file
|
|
@ -0,0 +1,819 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import JavaScript from 'tree-sitter-javascript';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import {
|
||||
DATA_ROUTE_TABLE_SOURCE,
|
||||
extractDataRouteTableRoutes,
|
||||
scanDataRouteTables,
|
||||
} from '../../src/core/ingestion/route-extractors/data-route-table.js';
|
||||
import { JAVASCRIPT_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/node.js';
|
||||
|
||||
const jsParser = new Parser();
|
||||
jsParser.setLanguage(JavaScript);
|
||||
|
||||
const compact = (source: string) =>
|
||||
extractDataRouteTableRoutes(jsParser.parse(source), 'src/routes.js').map((route) => ({
|
||||
path: route.routePath,
|
||||
method: route.httpMethod,
|
||||
handler: route.handlerName,
|
||||
source: route.source,
|
||||
}));
|
||||
|
||||
const dispatch = (table: string) => `
|
||||
for (const route of ${table}) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
`;
|
||||
|
||||
describe('data route table extraction', () => {
|
||||
it('extracts literal entries and preserves method + URL identity', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const apiRoutes = [
|
||||
{ path: '/users', method: 'get', handler: listUsers },
|
||||
{ path: '/users', method: 'POST', handler: users.create, auth: true },
|
||||
];
|
||||
${dispatch('apiRoutes')}
|
||||
`),
|
||||
).toEqual([
|
||||
{
|
||||
path: '/users',
|
||||
method: 'GET',
|
||||
handler: 'listUsers',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
method: 'POST',
|
||||
handler: 'users.create',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports quoted keys, static templates, and one-level member handlers', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const ROUTE_TABLE = [{
|
||||
'path': \`/auth/me\`,
|
||||
'method': 'GET',
|
||||
'handler': auth.getCurrentUser,
|
||||
}];
|
||||
${dispatch('ROUTE_TABLE')}
|
||||
`),
|
||||
).toMatchObject([{ path: '/auth/me', method: 'GET', handler: 'auth.getCurrentUser' }]);
|
||||
});
|
||||
|
||||
it('decodes JavaScript escapes in static route strings and quoted keys', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{
|
||||
'p\\u0061th': \`\\/users\`,
|
||||
method: 'G\\x45T',
|
||||
handler: escaped,
|
||||
}];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toMatchObject([{ path: '/users', method: 'GET', handler: 'escaped' }]);
|
||||
});
|
||||
|
||||
it('rejects malformed or legacy-octal route string escapes', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/\\8users', method: 'GET', handler: malformed }];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects executable values on extra route properties', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{
|
||||
path: '/users',
|
||||
method: 'GET',
|
||||
handler: listUsers,
|
||||
metadata: buildMetadata(),
|
||||
}];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps declarative metadata on route entries', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{
|
||||
path: '/users',
|
||||
method: 'GET',
|
||||
handler: listUsers,
|
||||
auth: true,
|
||||
metadata: { audience: 'staff', flags: ['audit'] },
|
||||
}];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toMatchObject([{ path: '/users', method: 'GET', handler: 'listUsers' }]);
|
||||
});
|
||||
|
||||
it('allows line and block comments between static route properties', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{
|
||||
path: '/comments',
|
||||
// The verb remains a direct literal.
|
||||
method: 'GET',
|
||||
/* The handler remains a direct designator. */
|
||||
handler: commented,
|
||||
}];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toMatchObject([{ path: '/comments', method: 'GET', handler: 'commented' }]);
|
||||
});
|
||||
|
||||
it('has JavaScript / TypeScript grammar parity', () => {
|
||||
const source = `
|
||||
type Route = { path: string; method: string; handler: Function };
|
||||
const routes: Route[] = [{ path: '/typed', method: 'GET', handler: typedHandler }];
|
||||
${dispatch('routes')}
|
||||
`;
|
||||
const tsParser = new Parser();
|
||||
tsParser.setLanguage(TypeScript.typescript);
|
||||
expect(scanDataRouteTables(tsParser.parse(source))).toMatchObject([
|
||||
{ path: '/typed', method: 'GET', handlerName: 'typedHandler' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['dynamic path', `const routes = [{ path: runtimePath, method: 'GET', handler: h }]`, 'routes'],
|
||||
[
|
||||
'dynamic method',
|
||||
`const routes = [{ path: '/x', method: runtimeMethod, handler: h }]`,
|
||||
'routes',
|
||||
],
|
||||
[
|
||||
'called handler',
|
||||
`const routes = [{ path: '/x', method: 'GET', handler: makeHandler() }]`,
|
||||
'routes',
|
||||
],
|
||||
[
|
||||
'inline handler',
|
||||
`const routes = [{ path: '/x', method: 'GET', handler: () => {} }]`,
|
||||
'routes',
|
||||
],
|
||||
[
|
||||
'spread entry',
|
||||
`const routes = [{ ...base, path: '/x', method: 'GET', handler: h }]`,
|
||||
'routes',
|
||||
],
|
||||
['computed key', `const routes = [{ ['path']: '/x', method: 'GET', handler: h }]`, 'routes'],
|
||||
['unknown verb', `const routes = [{ path: '/x', method: 'CONNECT', handler: h }]`, 'routes'],
|
||||
[
|
||||
'multi-level member',
|
||||
`const routes = [{ path: '/x', method: 'GET', handler: services.auth.h }]`,
|
||||
'routes',
|
||||
],
|
||||
[
|
||||
'non-route binding',
|
||||
`const requests = [{ path: '/x', method: 'GET', handler: h }]`,
|
||||
'requests',
|
||||
],
|
||||
])('suppresses %s', (_name, source, table) => {
|
||||
expect(compact(`${source}; ${dispatch(table)}`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses an unconsumed route-named descriptor table', () => {
|
||||
expect(
|
||||
compact(`const mockRoutes = [{ path: '/admin', method: 'DELETE', handler: onResponse }];`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not use a dispatch loop hidden in a nested function', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/admin', method: 'DELETE', handler: removeAdmin }];
|
||||
function dispatch(path, method) {
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses outbound client tables that pass fields to fetch and then callbacks', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routeRequests = [
|
||||
{ path: '/admin', method: 'DELETE', handler: onResponse },
|
||||
];
|
||||
for (const route of routeRequests) {
|
||||
fetch(route.path, { method: route.method }).then(route.handler);
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('requires path and method comparisons in the same guard as direct dispatch', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/admin', method: 'DELETE', handler: onResponse }];
|
||||
for (const route of routes) {
|
||||
if (route.path) console.log(route.method);
|
||||
route.handler();
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not treat negated equality as a positive dispatch guard', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/admin', method: 'DELETE', handler: removeAdmin }];
|
||||
for (const route of routes) {
|
||||
if (!(route.path === request.path && route.method === request.method)) {
|
||||
route.handler();
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not treat disjunctive comparisons as a complete dispatch guard', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/admin', method: 'DELETE', handler: removeAdmin }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path || route.method === request.method) route.handler();
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['direct', 'route.path', 'route.method'],
|
||||
['parenthesized', '(route.path)', '(route.method)'],
|
||||
['computed', "route['path']", "route['method']"],
|
||||
['unary', '+route.path', '+route.method'],
|
||||
['self-derived', 'normalize(route.path)', 'normalize(route.method)'],
|
||||
])(
|
||||
'does not accept %s route-field self-comparisons as dispatch evidence',
|
||||
(_name, path, method) => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/admin', method: 'DELETE', handler: removeAdmin }];
|
||||
for (const route of routes) {
|
||||
if (route.path === ${path} && route.method === ${method}) route.handler();
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('suppresses duplicate required keys but preserves route candidates for resolution', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [
|
||||
{ path: '/bad', path: '/other', method: 'GET', handler: bad },
|
||||
{ path: '/once', method: 'GET', handler: first },
|
||||
{ path: '/once', method: 'GET', handler: second },
|
||||
];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toMatchObject([
|
||||
{ path: '/once', method: 'GET', handler: 'first' },
|
||||
{ path: '/once', method: 'GET', handler: 'second' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects literal-only guards that select only one table entry', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [
|
||||
{ path: '/only', method: 'GET', handler: only },
|
||||
{ path: '/never', method: 'POST', handler: never },
|
||||
];
|
||||
for (const route of routes) {
|
||||
if (route.path === '/only' && route.method === 'GET') route.handler();
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects route-derived aliases even when their names resemble request fields', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/copied', method: 'GET', handler: copied }];
|
||||
for (const route of routes) {
|
||||
const copiedPath = route.path;
|
||||
const copiedMethod = route.method;
|
||||
if (route.path === copiedPath && route.method === copiedMethod) route.handler();
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('requires request fields to come from an unshadowed ingress binding', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const request = { path: '/only', method: 'GET' };
|
||||
const routes = [
|
||||
{ path: '/only', method: 'GET', handler: only },
|
||||
{ path: '/never', method: 'POST', handler: never },
|
||||
];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/nested', method: 'GET', handler: nested }];
|
||||
for (const route of routes) {
|
||||
if (
|
||||
route.path === request.config.path &&
|
||||
route.method === request.options.method
|
||||
) route.handler();
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
compact(`
|
||||
function dispatchRequest(request) {
|
||||
const routes = [{ path: '/parameter', method: 'GET', handler: parameterHandler }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toMatchObject([{ path: '/parameter', method: 'GET', handler: 'parameterHandler' }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['reassigned request parameter', 'request = { path: "/x", method: "GET" };'],
|
||||
['mutated request fields', 'request.path = "/x"; request.method = "GET";'],
|
||||
['escaped request parameter', 'observe(request);'],
|
||||
[
|
||||
'request mutation through a called closure',
|
||||
'const overwrite = () => { request.path = "/x"; request.method = "GET"; }; overwrite();',
|
||||
],
|
||||
])('rejects an unstable ingress binding: %s', (_name, mutation) => {
|
||||
expect(
|
||||
compact(`
|
||||
function dispatchRequest(request) {
|
||||
const routes = [{ path: '/x', method: 'GET', handler: handler }];
|
||||
${mutation}
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a route-literal narrowing conjunct',
|
||||
`route.path === request.path && route.method === request.method && route.path === '/only'`,
|
||||
],
|
||||
[
|
||||
'a statically false conjunct',
|
||||
`route.path === request.path && route.method === request.method && false`,
|
||||
],
|
||||
[
|
||||
'a statically false equality',
|
||||
`route.path === request.path && route.method === request.method && 1 === 2`,
|
||||
],
|
||||
])('rejects %s', (_name, condition) => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [
|
||||
{ path: '/only', method: 'GET', handler: only },
|
||||
{ path: '/other', method: 'GET', handler: other },
|
||||
];
|
||||
for (const route of routes) {
|
||||
if (${condition}) route.handler();
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a conditionally unreachable handler call in the consequence', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/x', method: 'GET', handler: unreachable }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) {
|
||||
false && route.handler();
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a handler call after a statically terminating branch', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/x', method: 'GET', handler: unreachable }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) {
|
||||
if (true) return;
|
||||
route.handler();
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['return', 'throw new Error("stop")', 'break', 'continue'])(
|
||||
'rejects a handler call after an unconditional %s',
|
||||
(transfer) => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/x', method: 'GET', handler: unreachable }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) {
|
||||
${transfer};
|
||||
route.handler();
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['return', 'throw new Error("stop")', 'break', 'continue'])(
|
||||
'rejects a dispatch guard after an unconditional %s',
|
||||
(transfer) => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/x', method: 'GET', handler: unreachable }];
|
||||
for (const route of routes) {
|
||||
${transfer};
|
||||
if (route.path === request.path && route.method === request.method) {
|
||||
route.handler();
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a dispatch guard nested in a statically false branch', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/x', method: 'GET', handler: unreachable }];
|
||||
for (const route of routes) {
|
||||
if (false) {
|
||||
if (route.path === request.path && route.method === request.method) {
|
||||
route.handler();
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects mutation of the loop entry before dispatch', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/mutated', method: 'GET', handler: mutated }];
|
||||
for (const route of routes) {
|
||||
route.path = request.path;
|
||||
route.method = request.method;
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/reassigned', method: 'GET', handler: original }];
|
||||
for (let route of routes) {
|
||||
route = replacement;
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['deleting an entry field', 'delete route.path;'],
|
||||
['deleting a wrapped entry field', 'delete (route.path);'],
|
||||
['writing through a destructuring target', '({ path: route.path } = request);'],
|
||||
['augmenting the handler field', 'route.handler ||= replacement;'],
|
||||
['aliasing the entry', 'const alias = route; alias.path = request.path;'],
|
||||
])('rejects %s', (_name, mutation) => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/mutated', method: 'GET', handler: original }];
|
||||
for (const route of routes) {
|
||||
${mutation}
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a mutable declaration',
|
||||
`let routes = [{ path: '/stale', method: 'GET', handler: stale }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a reassigned table',
|
||||
`const routes = [{ path: '/stale', method: 'GET', handler: stale }];
|
||||
routes = [];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'an aliased table',
|
||||
`const routes = [{ path: '/stale', method: 'GET', handler: stale }];
|
||||
const alias = routes;
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a truncated table',
|
||||
`const routes = [{ path: '/stale', method: 'GET', handler: stale }];
|
||||
routes.length = 0;
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
])('rejects %s', (_name, source) => {
|
||||
expect(compact(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a reassigned bare handler',
|
||||
`function original() {}
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: original }];
|
||||
original = replacement;
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a reassigned member handler',
|
||||
`const auth = {};
|
||||
auth.handle = replacement;
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a handler owner mutated through an alias',
|
||||
`const auth = {};
|
||||
const alias = auth;
|
||||
alias.handle = replacement;
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a handler owner hidden by parentheses',
|
||||
`const auth = {};
|
||||
const alias = (auth);
|
||||
alias.handle = replacement;
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a handler owner nested in an initializer',
|
||||
`const auth = {};
|
||||
const box = { auth };
|
||||
box.auth.handle = replacement;
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a handler owner stored under an unrelated handler property',
|
||||
`const auth = {};
|
||||
const box = { handler: auth };
|
||||
box.handler.handle = replacement;
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a handler owner assigned to an alias',
|
||||
`const auth = {};
|
||||
let alias;
|
||||
alias = auth;
|
||||
alias.handle = replacement;
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'an escaped handler owner',
|
||||
`const auth = {};
|
||||
auth.configure();
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a handler owner escaped through a computed member call',
|
||||
`const auth = {};
|
||||
auth['configure']();
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a parenthesized handler owner escaped through a member call',
|
||||
`const auth = {};
|
||||
(auth).configure();
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
[
|
||||
'a returned handler owner',
|
||||
`const auth = {};
|
||||
function expose() { return auth; }
|
||||
expose().handle = replacement;
|
||||
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
|
||||
${dispatch('routes')}`,
|
||||
],
|
||||
])('rejects %s', (_name, source) => {
|
||||
expect(compact(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves an unresolved identity tombstone for unsupported duplicate handlers', () => {
|
||||
expect(
|
||||
compact(`
|
||||
function valid() {}
|
||||
const routes = [
|
||||
{ path: '/users', method: 'GET', handler: makeHandler() },
|
||||
{ path: '/users', method: 'GET', handler: valid },
|
||||
];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('tombstones recoverable identities from entries with duplicate route keys', () => {
|
||||
expect(
|
||||
compact(`
|
||||
function first() {}
|
||||
function second() {}
|
||||
const routes = [
|
||||
{ path: '/users', path: '/users', method: 'GET', handler: first },
|
||||
{ path: '/users', method: 'GET', handler: second },
|
||||
];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the final duplicate route keys when tombstoning an invalid entry', () => {
|
||||
expect(
|
||||
compact(`
|
||||
function first() {}
|
||||
function second() {}
|
||||
const routes = [
|
||||
{ path: '/ignored', path: '/users', method: 'POST', method: 'GET', handler: first },
|
||||
{ path: '/users', method: 'GET', handler: second },
|
||||
];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses later candidates when a trailing spread leaves identity unknown', () => {
|
||||
expect(
|
||||
compact(`
|
||||
function first() {}
|
||||
function second() {}
|
||||
const routes = [
|
||||
{ path: '/unknown', method: 'POST', handler: first, ...dynamicRoute },
|
||||
{ path: '/users', method: 'GET', handler: second },
|
||||
];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a dynamic path', `{ path: runtimePath, method: 'GET', handler: first }`],
|
||||
[
|
||||
'a computed overwrite',
|
||||
`{ path: '/other', method: 'GET', handler: first, ['path']: '/users' }`,
|
||||
],
|
||||
])('suppresses later candidates after %s', (_name, firstEntry) => {
|
||||
expect(
|
||||
compact(`
|
||||
function first() {}
|
||||
function second() {}
|
||||
const routes = [
|
||||
${firstEntry},
|
||||
{ path: '/users', method: 'GET', handler: second },
|
||||
];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects tables containing array-level spreads', () => {
|
||||
expect(
|
||||
compact(`
|
||||
function second() {}
|
||||
const routes = [
|
||||
...baseRoutes,
|
||||
{ path: '/users', method: 'GET', handler: second },
|
||||
];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('propagates unresolved identity tombstones across route tables', () => {
|
||||
expect(
|
||||
compact(`
|
||||
function first() {}
|
||||
function second() {}
|
||||
const primaryRoutes = [{ path: '/users', method: 'GET', handler: first }];
|
||||
const fallbackRoutes = [{ path: '/users', method: 'GET', handler: makeHandler() }];
|
||||
${dispatch('primaryRoutes')}
|
||||
${dispatch('fallbackRoutes')}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an unstable duplicate handler binding',
|
||||
`function first() {}
|
||||
function second() {}
|
||||
first = replacement;
|
||||
const routes = [
|
||||
{ path: '/users', method: 'GET', handler: first },
|
||||
{ path: '/users', method: 'GET', handler: second },
|
||||
];`,
|
||||
],
|
||||
[
|
||||
'a spread-first duplicate entry',
|
||||
`function second() {}
|
||||
const routes = [
|
||||
{ ...base, path: '/users', method: 'GET', handler: first },
|
||||
{ path: '/users', method: 'GET', handler: second },
|
||||
];`,
|
||||
],
|
||||
])('tombstones %s', (_name, table) => {
|
||||
expect(compact(`${table} ${dispatch('routes')}`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects shadowed table and loop-entry bindings', () => {
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/outer', method: 'GET', handler: outer }];
|
||||
{
|
||||
const routes = [{ path: '/inner', method: 'GET', handler: inner }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
compact(`
|
||||
const routes = [{ path: '/outer', method: 'GET', handler: outer }];
|
||||
for (const route of routes) {
|
||||
{
|
||||
const route = fakeRoute;
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses a named import shadowed anywhere in the file', () => {
|
||||
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(
|
||||
jsParser.parse(`
|
||||
import { listUsers } from './handlers.js';
|
||||
function wrapper(listUsers) { return listUsers; }
|
||||
const routes = [{ path: '/users', method: 'GET', handler: listUsers }];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
);
|
||||
expect(detections).not.toContainEqual(
|
||||
expect.objectContaining({ framework: DATA_ROUTE_TABLE_SOURCE, path: '/users' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('feeds the Node group scanner with named-import provenance', () => {
|
||||
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(
|
||||
jsParser.parse(`
|
||||
import { listUsers as handleUsers } from './handlers.js';
|
||||
const routes = [{ path: '/users', method: 'GET', handler: handleUsers }];
|
||||
${dispatch('routes')}
|
||||
`),
|
||||
);
|
||||
expect(detections).toContainEqual({
|
||||
role: 'provider',
|
||||
framework: DATA_ROUTE_TABLE_SOURCE,
|
||||
method: 'GET',
|
||||
path: '/users',
|
||||
name: 'listUsers',
|
||||
handlerImport: { name: 'listUsers', module: './handlers.js' },
|
||||
strictHandlerResolution: true,
|
||||
line: 3,
|
||||
confidence: 0.8,
|
||||
});
|
||||
});
|
||||
});
|
||||
259
gitnexus/test/unit/group/data-route-table.test.ts
Normal file
259
gitnexus/test/unit/group/data-route-table.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js';
|
||||
import type { RepoHandle } from '../../../src/core/group/types.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from '../../../src/core/ingestion/route-extractors/data-route-table.js';
|
||||
|
||||
describe('data route table group contracts', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const repo = (): RepoHandle => ({
|
||||
id: 'route-table-test',
|
||||
path: 'test/route-table',
|
||||
repoPath: tmpDir,
|
||||
storagePath: path.join(tmpDir, '.gitnexus'),
|
||||
});
|
||||
|
||||
it('resolves imported handlers and leaves source-only members unattributed', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'routes.js'),
|
||||
`import { listUsers as handleUsers } from './handlers.js';
|
||||
const routes = [
|
||||
{ path: '/users', method: 'GET', handler: handleUsers },
|
||||
{ path: '/auth/me', method: 'GET', handler: auth.getCurrentUser },
|
||||
];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'handlers.js'),
|
||||
`export function listUsers() { return []; }
|
||||
export const auth = { getCurrentUser() { return {}; } };
|
||||
`,
|
||||
);
|
||||
|
||||
const queriedNames: string[] = [];
|
||||
const db = async (_query: string, params?: Record<string, unknown>) => {
|
||||
const name = String(params?.name ?? '');
|
||||
queriedNames.push(name);
|
||||
if (name !== 'listUsers' && name !== 'getCurrentUser') return [];
|
||||
return [
|
||||
{
|
||||
uid: `symbol:${name}`,
|
||||
name,
|
||||
filePath: 'handlers.js',
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
labels: ['Function'],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
|
||||
const providers = contracts.filter(
|
||||
(contract) =>
|
||||
contract.role === 'provider' && contract.meta.framework === DATA_ROUTE_TABLE_SOURCE,
|
||||
);
|
||||
|
||||
expect(
|
||||
providers
|
||||
.map((provider) => ({
|
||||
contractId: provider.contractId,
|
||||
symbolUid: provider.symbolUid,
|
||||
symbolName: provider.symbolName,
|
||||
extractionStrategy: provider.meta.extractionStrategy,
|
||||
}))
|
||||
.sort((left, right) => left.contractId.localeCompare(right.contractId)),
|
||||
).toEqual([
|
||||
{
|
||||
contractId: 'http::GET::/auth/me',
|
||||
symbolUid: '',
|
||||
symbolName: 'handler',
|
||||
extractionStrategy: 'source_scan',
|
||||
},
|
||||
{
|
||||
contractId: 'http::GET::/users',
|
||||
symbolUid: 'symbol:listUsers',
|
||||
symbolName: 'listUsers',
|
||||
extractionStrategy: 'source_scan_resolved',
|
||||
},
|
||||
]);
|
||||
expect(queriedNames).not.toContain('getCurrentUser');
|
||||
});
|
||||
|
||||
it('keeps ambiguous handlers unattributed instead of guessing', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'routes.js'),
|
||||
`const routes = [{ path: '/users', method: 'GET', handler: listUsers }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}`,
|
||||
);
|
||||
const db = async (_query: string, params?: Record<string, unknown>) =>
|
||||
params?.name === 'listUsers'
|
||||
? [
|
||||
{ uid: 'a', name: 'listUsers', filePath: 'a.js' },
|
||||
{ uid: 'b', name: 'listUsers', filePath: 'b.js' },
|
||||
]
|
||||
: [];
|
||||
|
||||
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
|
||||
const provider = contracts.find((contract) => contract.contractId === 'http::GET::/users');
|
||||
|
||||
expect(provider).toMatchObject({ symbolUid: '', symbolName: 'listUsers' });
|
||||
});
|
||||
|
||||
it('keeps duplicate same-file handlers unattributed', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'routes.js'),
|
||||
`const routes = [{ path: '/users', method: 'GET', handler: listUsers }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}`,
|
||||
);
|
||||
const db = async (query: string) =>
|
||||
query.includes('UNION ALL')
|
||||
? [
|
||||
{ uid: 'a', name: 'listUsers', filePath: 'routes.js', labels: ['Function'] },
|
||||
{ uid: 'b', name: 'listUsers', filePath: 'routes.js', labels: ['Function'] },
|
||||
]
|
||||
: [];
|
||||
|
||||
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
|
||||
const provider = contracts.find((contract) => contract.contractId === 'http::GET::/users');
|
||||
|
||||
expect(provider).toMatchObject({ symbolUid: '', symbolName: 'listUsers' });
|
||||
});
|
||||
|
||||
it('does not bind an unimported handler to a unique same-name symbol in another file', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'routes.js'),
|
||||
`const routes = [{ path: '/users', method: 'GET', handler: listUsers }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}`,
|
||||
);
|
||||
const db = async (query: string, params?: Record<string, unknown>) => {
|
||||
if (query.includes('UNION ALL')) return [];
|
||||
return params?.name === 'listUsers'
|
||||
? [{ uid: 'decoy', name: 'listUsers', filePath: 'unrelated.js' }]
|
||||
: [];
|
||||
};
|
||||
|
||||
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
|
||||
const provider = contracts.find((contract) => contract.contractId === 'http::GET::/users');
|
||||
|
||||
expect(provider).toMatchObject({ symbolUid: '', symbolName: 'listUsers' });
|
||||
});
|
||||
|
||||
it('does not fall back from an unresolved import to a same-name local symbol', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'routes.js'),
|
||||
`import { listUsers } from 'external-router-package';
|
||||
const routes = [{ path: '/users', method: 'GET', handler: listUsers }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}`,
|
||||
);
|
||||
const db = async (_query: string, params?: Record<string, unknown>) =>
|
||||
params?.name === 'listUsers'
|
||||
? [{ uid: 'local-decoy', name: 'listUsers', filePath: 'local.js' }]
|
||||
: [];
|
||||
|
||||
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
|
||||
const provider = contracts.find((contract) => contract.contractId === 'http::GET::/users');
|
||||
|
||||
expect(provider).toMatchObject({ symbolUid: '', symbolName: 'listUsers' });
|
||||
});
|
||||
|
||||
it('does not attribute an unresolved member handler to its registrar function', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'routes.js'),
|
||||
`function setupRoutes(path, method) {
|
||||
const routes = [{ path: '/me', method: 'GET', handler: auth.getCurrentUser }];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
const db = async (query: string) =>
|
||||
query.includes('UNION ALL')
|
||||
? [
|
||||
{
|
||||
uid: 'function:setupRoutes',
|
||||
name: 'setupRoutes',
|
||||
filePath: 'routes.js',
|
||||
startLine: 0,
|
||||
endLine: 5,
|
||||
labels: ['Function'],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
|
||||
const provider = contracts.find((contract) => contract.contractId === 'http::GET::/me');
|
||||
|
||||
expect(provider).toMatchObject({ symbolUid: '', symbolName: 'handler' });
|
||||
});
|
||||
|
||||
it('suppresses duplicate identities when any data-table handler is unresolved', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'routes.js'),
|
||||
`function second() {}
|
||||
const routes = [
|
||||
{ path: '/users', method: 'GET', handler: missing },
|
||||
{ path: '/users', method: 'GET', handler: second },
|
||||
];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}`,
|
||||
);
|
||||
const db = async (query: string) =>
|
||||
query.includes('UNION ALL')
|
||||
? [{ uid: 'second', name: 'second', filePath: 'routes.js', labels: ['Function'] }]
|
||||
: [];
|
||||
|
||||
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
|
||||
|
||||
expect(contracts.some((contract) => contract.contractId === 'http::GET::/users')).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses duplicate identities that resolve to different handlers', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'routes.js'),
|
||||
`function first() {}
|
||||
function second() {}
|
||||
const routes = [
|
||||
{ path: '/users', method: 'GET', handler: first },
|
||||
{ path: '/users', method: 'GET', handler: second },
|
||||
];
|
||||
for (const route of routes) {
|
||||
if (route.path === request.path && route.method === request.method) route.handler();
|
||||
}`,
|
||||
);
|
||||
const db = async (query: string) =>
|
||||
query.includes('UNION ALL')
|
||||
? [
|
||||
{ uid: 'first', name: 'first', filePath: 'routes.js', labels: ['Function'] },
|
||||
{ uid: 'second', name: 'second', filePath: 'routes.js', labels: ['Function'] },
|
||||
]
|
||||
: [];
|
||||
|
||||
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
|
||||
|
||||
expect(contracts.some((contract) => contract.contractId === 'http::GET::/users')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -219,6 +219,7 @@ export default router;
|
|||
): Promise<Record<string, unknown>[]> => {
|
||||
if (query.includes('UNION ALL'))
|
||||
return String(params?.filePath ?? '').includes('routes.ts') ? routesFileSyms : [];
|
||||
if (query.includes('filePath IN $filePaths')) return [];
|
||||
if (query.includes('n.name = $name'))
|
||||
return params?.name === 'listUsers'
|
||||
? [{ uid: 'fn-listUsers-xfile', name: 'listUsers', filePath: 'src/handlers/users.ts' }]
|
||||
|
|
@ -229,6 +230,36 @@ export default router;
|
|||
expect(provider).toMatchObject({ symbolUid: 'fn-listUsers-xfile', symbolName: 'listUsers' });
|
||||
});
|
||||
|
||||
it('retains unique-name fallback for a Flask handler imported from a bare module', async () => {
|
||||
const dir = path.join(tmpDir, 'py-flask-bare-import');
|
||||
fs.mkdirSync(path.join(dir, 'app'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'app/routes.py'),
|
||||
`from flask import Flask
|
||||
from shared_handlers import list_users
|
||||
app = Flask(__name__)
|
||||
app.add_url_rule('/api/users', view_func=list_users)
|
||||
`,
|
||||
);
|
||||
const mockDbExecutor = async (
|
||||
query: string,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>[]> => {
|
||||
if (query.includes('UNION ALL') || query.includes('filePath IN $filePaths')) return [];
|
||||
if (query.includes('n.name = $name') && params?.name === 'list_users') {
|
||||
return [{ uid: 'fn-list-users', name: 'list_users', filePath: 'shared/handlers.py' }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
|
||||
const provider = contracts.find(
|
||||
(contract) =>
|
||||
contract.role === 'provider' && contract.contractId === 'http::GET::/api/users',
|
||||
);
|
||||
expect(provider).toMatchObject({ symbolUid: 'fn-list-users', symbolName: 'list_users' });
|
||||
});
|
||||
|
||||
it('leaves symbolUid empty when the repo-wide name is AMBIGUOUS (multiple matches)', async () => {
|
||||
const dir = writeCrossFile('xfile-ambiguous');
|
||||
const mockDbExecutor = async (
|
||||
|
|
@ -591,6 +622,49 @@ app.add_url_rule('/api/users', view_func=handle_users)
|
|||
expect(queriedNames).not.toContain('module:handle_users');
|
||||
});
|
||||
|
||||
it('retains legacy package-prefix resolution for a Python re-export', async () => {
|
||||
const dir = path.join(tmpDir, 'py-flask-reexport');
|
||||
fs.mkdirSync(path.join(dir, 'app', 'handlers'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'app/routes.py'),
|
||||
`from flask import Flask
|
||||
from .handlers import list_users
|
||||
app = Flask(__name__)
|
||||
app.add_url_rule('/api/users', view_func=list_users)
|
||||
`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'app', 'handlers', '__init__.py'),
|
||||
`from .users import list_users\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'app', 'handlers', 'users.py'),
|
||||
`def list_users(): return []\n`,
|
||||
);
|
||||
const mockDbExecutor = async (
|
||||
query: string,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>[]> => {
|
||||
if (query.includes('STARTS WITH') && String(params?.fileSlash) === 'app/handlers/') {
|
||||
return [
|
||||
{
|
||||
uid: 'fn-list-users',
|
||||
name: 'list_users',
|
||||
filePath: 'app/handlers/users.py',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
|
||||
const provider = contracts.find(
|
||||
(contract) =>
|
||||
contract.role === 'provider' && contract.contractId === 'http::GET::/api/users',
|
||||
);
|
||||
expect(provider).toMatchObject({ symbolUid: 'fn-list-users', symbolName: 'list_users' });
|
||||
});
|
||||
|
||||
// ── Inline / closure provider handlers (#2276) ──────────────────────
|
||||
// An inline provider handler has no name, so it must resolve by line-span
|
||||
// containment to the symbol it lives in — exactly like a consumer. Mirrors
|
||||
|
|
|
|||
|
|
@ -218,17 +218,16 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// the pre-fix fan-out. This branch staged 64 above the claims live at the
|
||||
// time (61, 62, 63); all three landed and cascaded main to 67, so 68 is the
|
||||
// next free value above every claim at merge — the rule, re-applied.
|
||||
// Moved 68 -> 70 for Spring non-HTTP handler side-channel facts (#2417 /
|
||||
// #2891). Main holds 68 and open PR #2972 publishes 69, so 70 is the next
|
||||
// free value above every known claim at this merge.
|
||||
// Version 69 added #2969's JS/TS data-route-table decoratorRoutes. Version 70
|
||||
// adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so it is
|
||||
// the next free value after both cache payload changes.
|
||||
it('pins SCHEMA_BUMP to 70 so concurrent bumps cannot silently collide (#2766)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(70);
|
||||
// The PREVIOUS version must fail the reuse gate, not merely differ from the
|
||||
// current one — a hardcoded number outside the conflict hunk rebases cleanly
|
||||
// while being wrong, which is exactly how the 37/38 exact clashes landed.
|
||||
// Every nearby historical or in-flight value is rejected: this branch
|
||||
// previously published 59, origin/main advanced through 68, and #2972
|
||||
// publishes 69. Rejecting all of them makes a bad conflict resolution loud.
|
||||
// Every nearby historical or in-flight value is rejected, including 69,
|
||||
// which carried the route-table payload before this merge.
|
||||
for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69]) {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
* Pins the P2 fixes from the review:
|
||||
* - ambiguity → fail-open: a same-name lookup returning ≠1 yields NO
|
||||
* handlerSymbolId (never an arbitrary `[0]` guess).
|
||||
* - first-writer-wins reservation: the first route to claim a route identity
|
||||
* reserves it even when its handler is unresolvable, so a later same-identity
|
||||
* route can't stamp its handler onto the (node-winning) first route's slot.
|
||||
* - first-writer-wins reservation: ordinary route declarations reserve an
|
||||
* identity even when unresolved; unproven data-table entries do not because
|
||||
* the routes phase suppresses them entirely.
|
||||
* - happy path: a uniquely-resolvable handler is stamped, keyed by the route's
|
||||
* `(method, url)` identity (`routeNodeKey`).
|
||||
* - multi-verb identity (#2289): `GET /x` and `POST /x` are distinct keys, so
|
||||
|
|
@ -18,6 +18,7 @@ import { resolveRouteHandlerSymbols } from '../../src/core/ingestion/call-proces
|
|||
import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js';
|
||||
import type { ExtractedDecoratorRoute } from '../../src/core/ingestion/workers/parse-worker.js';
|
||||
import type { ExtractedRoute } from '../../src/core/ingestion/route-extractors/laravel.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from '../../src/core/ingestion/route-extractors/data-route-table.js';
|
||||
|
||||
const FILE = 'src/OrderController.java';
|
||||
|
||||
|
|
@ -119,6 +120,402 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => {
|
|||
expect(out.get(routeNodeKey('GET', '/orders'))).toBe('method:OrderController.list');
|
||||
expect(out.get(routeNodeKey('POST', '/orders'))).toBe('method:OrderController.create');
|
||||
});
|
||||
|
||||
it('data-table bare handlers resolve in their lexical file', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add('src/routes.js', 'list', 'function:list', 'Function');
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'list',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(out.get(GET_ORDERS)).toBe('function:list');
|
||||
});
|
||||
|
||||
it('data-table named imports resolve only exported callables', () => {
|
||||
const model = createSemanticModel();
|
||||
const exported = model.symbols.add(
|
||||
'src/handlers.js',
|
||||
'listUsers',
|
||||
'function:listUsers',
|
||||
'Function',
|
||||
);
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'handleUsers',
|
||||
}),
|
||||
],
|
||||
{
|
||||
files: [
|
||||
{
|
||||
filePath: 'src/routes.js',
|
||||
localDefs: [],
|
||||
parsedImports: [
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'handleUsers',
|
||||
importedName: 'listUsers',
|
||||
targetRaw: './handlers.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
resolveImportTarget: () => 'src/handlers.js',
|
||||
isExportedSymbol: (nodeId) => nodeId === exported.nodeId,
|
||||
},
|
||||
);
|
||||
|
||||
expect(out.get(GET_ORDERS)).toBe(exported.nodeId);
|
||||
});
|
||||
|
||||
it('data-table named imports reject private callables', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add('src/handlers.js', 'listUsers', 'function:listUsers', 'Function');
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'handleUsers',
|
||||
}),
|
||||
],
|
||||
{
|
||||
files: [
|
||||
{
|
||||
filePath: 'src/routes.js',
|
||||
localDefs: [],
|
||||
parsedImports: [
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'handleUsers',
|
||||
importedName: 'listUsers',
|
||||
targetRaw: './handlers.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
resolveImportTarget: () => 'src/handlers.js',
|
||||
isExportedSymbol: () => false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('data-table named-import members reject private owners', () => {
|
||||
const model = createSemanticModel();
|
||||
const owner = model.symbols.add('src/handlers.js', 'auth', 'object:auth', 'Variable');
|
||||
model.methods.register(owner.nodeId, 'getCurrentUser', {
|
||||
filePath: 'src/handlers.js',
|
||||
name: 'getCurrentUser',
|
||||
nodeId: 'method:auth.getCurrentUser',
|
||||
type: 'Method',
|
||||
ownerId: owner.nodeId,
|
||||
});
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'authService.getCurrentUser',
|
||||
}),
|
||||
],
|
||||
{
|
||||
files: [
|
||||
{
|
||||
filePath: 'src/routes.js',
|
||||
localDefs: [],
|
||||
parsedImports: [
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'authService',
|
||||
importedName: 'auth',
|
||||
targetRaw: './handlers.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
resolveImportTarget: () => 'src/handlers.js',
|
||||
isExportedSymbol: () => false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('data-table members resolve through their proven same-file owner', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add('src/routes.js', 'auth', 'object:auth', 'Variable');
|
||||
model.methods.register('object:auth', 'getCurrentUser', {
|
||||
filePath: 'src/routes.js',
|
||||
name: 'getCurrentUser',
|
||||
nodeId: 'method:auth.getCurrentUser',
|
||||
type: 'Method',
|
||||
ownerId: 'object:auth',
|
||||
});
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'auth.getCurrentUser',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(out.get(GET_ORDERS)).toBe('method:auth.getCurrentUser');
|
||||
});
|
||||
|
||||
it('data-table members refuse an unrelated terminal-name decoy', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add('src/routes.js', 'getCurrentUser', 'function:decoy', 'Function');
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'externalAuth.getCurrentUser',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('data-table members suppress unsupported multi-level receiver chains', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add('src/routes.js', 'services', 'object:services', 'Variable');
|
||||
model.methods.register('object:services', 'getCurrentUser', {
|
||||
filePath: 'src/routes.js',
|
||||
name: 'getCurrentUser',
|
||||
nodeId: 'method:decoy',
|
||||
type: 'Method',
|
||||
ownerId: 'object:services',
|
||||
});
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'services.auth.getCurrentUser',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('an unresolved data-table entry does not reserve a valid framework route identity', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method');
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'missing.handler',
|
||||
}),
|
||||
decoratorRoute({ handlerName: 'list' }),
|
||||
],
|
||||
);
|
||||
|
||||
expect(out.get(GET_ORDERS)).toBe('method:OrderController.list');
|
||||
});
|
||||
|
||||
it('an unresolved data-table duplicate suppresses the route identity', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add('src/routes.js', 'second', 'function:second', 'Function');
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'missing',
|
||||
}),
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'second',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('different resolvable handlers for one data-table identity are suppressed', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add('src/routes.js', 'first', 'function:first', 'Function');
|
||||
model.symbols.add('src/routes.js', 'second', 'function:second', 'Function');
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'first',
|
||||
}),
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'second',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed for default imports even when the target file has an exported callable', () => {
|
||||
const model = createSemanticModel();
|
||||
const exported = model.symbols.add(
|
||||
'src/handlers.js',
|
||||
'listUsers',
|
||||
'function:listUsers',
|
||||
'Function',
|
||||
);
|
||||
const privateHelper = model.symbols.add(
|
||||
'src/handlers.js',
|
||||
'helper',
|
||||
'function:helper',
|
||||
'Function',
|
||||
);
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'handleUsers',
|
||||
}),
|
||||
],
|
||||
{
|
||||
files: [
|
||||
{
|
||||
filePath: 'src/routes.js',
|
||||
localDefs: [],
|
||||
parsedImports: [
|
||||
{
|
||||
kind: 'alias',
|
||||
localName: 'handleUsers',
|
||||
importedName: 'default',
|
||||
alias: 'handleUsers',
|
||||
targetRaw: './handlers.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
filePath: 'src/handlers.js',
|
||||
localDefs: [exported, privateHelper],
|
||||
parsedImports: [],
|
||||
},
|
||||
],
|
||||
resolveImportTarget: () => 'src/handlers.js',
|
||||
isExportedSymbol: (nodeId) => nodeId === exported.nodeId,
|
||||
},
|
||||
);
|
||||
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not infer a barrel helper as a default re-export target', () => {
|
||||
const model = createSemanticModel();
|
||||
const helper = model.symbols.add('src/barrel.js', 'helper', 'function:helper', 'Function');
|
||||
|
||||
const out = resolveRouteHandlerSymbols(
|
||||
model,
|
||||
[],
|
||||
[
|
||||
decoratorRoute({
|
||||
filePath: 'src/routes.js',
|
||||
source: DATA_ROUTE_TABLE_SOURCE,
|
||||
handlerName: 'handleUsers',
|
||||
}),
|
||||
],
|
||||
{
|
||||
files: [
|
||||
{
|
||||
filePath: 'src/routes.js',
|
||||
localDefs: [],
|
||||
parsedImports: [
|
||||
{
|
||||
kind: 'alias',
|
||||
localName: 'handleUsers',
|
||||
importedName: 'default',
|
||||
alias: 'handleUsers',
|
||||
targetRaw: './barrel.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
filePath: 'src/barrel.js',
|
||||
localDefs: [{ ...helper, nodeId: 'def:helper', qualifiedName: 'helper' }],
|
||||
parsedImports: [
|
||||
{
|
||||
kind: 'named',
|
||||
localName: 'default',
|
||||
importedName: 'default',
|
||||
targetRaw: './actual.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
resolveImportTarget: (parsedImport) =>
|
||||
parsedImport.targetRaw === './barrel.js' ? 'src/barrel.js' : 'src/actual.js',
|
||||
isExportedSymbol: (nodeId) => nodeId === helper.nodeId,
|
||||
},
|
||||
);
|
||||
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it('ordinary decorator routes do not gain the repo-wide fallback', () => {
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add('src/other.js', 'list', 'function:other.list', 'Function');
|
||||
|
||||
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]);
|
||||
|
||||
expect(out.has(GET_ORDERS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRouteHandlerSymbols — Laravel framework routes', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue