mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-28 01:31:23 +00:00
Merge branch 'main' into codex/cpp-variadic-dependent-resolution
This commit is contained in:
commit
a8ca37ad89
24 changed files with 2323 additions and 349 deletions
|
|
@ -86,16 +86,33 @@ const NEW_QUALIFIED_CTOR_SPEC: PatternSpec<Record<string, never>> = {
|
|||
// proto loader). Matches either a bare call or an `obj.loadPackageDefinition(...)`
|
||||
// call. Plugin gates the qualified-constructor consumer on this —
|
||||
// structural check avoids materializing `tree.rootNode.text` for every file.
|
||||
const LOAD_PACKAGE_DEFINITION_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: [
|
||||
(identifier) @fn (#eq? @fn "loadPackageDefinition")
|
||||
(member_expression property: (property_identifier) @fn (#eq? @fn "loadPackageDefinition"))
|
||||
])
|
||||
`,
|
||||
};
|
||||
//
|
||||
// These are TWO separate specs, NOT one `function: [ (identifier) ... (member_expression) ... ]`
|
||||
// alternation. Under the pinned tree-sitter@0.21.1 binding a top-level alternation
|
||||
// whose branches reuse the same capture name (`@fn`) collapses to one pattern with
|
||||
// a shared predicate bucket; the second branch's `@fn` is left unbound and its
|
||||
// `#eq?` is never enforced, so the member-expression branch would match EVERY
|
||||
// `obj.method(...)` call (e.g. `console.log(...)`) — turning this gate always-on
|
||||
// and emitting spurious qualified-constructor consumers. Two specs compile to two
|
||||
// queries with independent predicate buckets; `runCompiledPatterns` concatenates
|
||||
// their matches, so the `.length > 0` gate still means "either form is present".
|
||||
const LOAD_PACKAGE_DEFINITION_SPECS: PatternSpec<Record<string, never>>[] = [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: (identifier) @fn (#eq? @fn "loadPackageDefinition"))
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @fn (#eq? @fn "loadPackageDefinition")))
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
interface NodeGrpcPatternBundle {
|
||||
grpcMethod: CompiledPatterns<Record<string, never>>;
|
||||
|
|
@ -107,11 +124,14 @@ interface NodeGrpcPatternBundle {
|
|||
}
|
||||
|
||||
function compileBundle(language: unknown, name: string): NodeGrpcPatternBundle {
|
||||
const mk = (spec: PatternSpec<Record<string, never>>, suffix: string) =>
|
||||
const mk = (
|
||||
spec: PatternSpec<Record<string, never>> | PatternSpec<Record<string, never>>[],
|
||||
suffix: string,
|
||||
) =>
|
||||
compilePatterns({
|
||||
name: `${name}-${suffix}`,
|
||||
language,
|
||||
patterns: [spec],
|
||||
patterns: Array.isArray(spec) ? spec : [spec],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
return {
|
||||
grpcMethod: mk(GRPC_METHOD_SPEC, 'grpc-method'),
|
||||
|
|
@ -119,7 +139,7 @@ function compileBundle(language: unknown, name: string): NodeGrpcPatternBundle {
|
|||
getService: mk(GET_SERVICE_SPEC, 'get-service'),
|
||||
newSimpleCtor: mk(NEW_SIMPLE_CTOR_SPEC, 'new-simple-ctor'),
|
||||
newQualifiedCtor: mk(NEW_QUALIFIED_CTOR_SPEC, 'new-qualified-ctor'),
|
||||
loadPackageDefinition: mk(LOAD_PACKAGE_DEFINITION_SPEC, 'load-package-definition'),
|
||||
loadPackageDefinition: mk(LOAD_PACKAGE_DEFINITION_SPECS, 'load-package-definition'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,14 +19,18 @@ import type {
|
|||
* - Spring `RestTemplate.getForObject/...`, `exchange(...)`
|
||||
* - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)`
|
||||
* - OkHttp `new Request.Builder().url("...")`
|
||||
* - OpenFeign interfaces with Spring MVC method annotations
|
||||
* - OpenFeign interfaces with Spring MVC method annotations or
|
||||
* native `@RequestLine("METHOD /path")` annotations
|
||||
* - Java / Apache HttpClient literal request construction
|
||||
*
|
||||
* The plugin runs two pattern bundles: one to collect class-level
|
||||
* `@RequestMapping` prefixes keyed by the enclosing class node, and a
|
||||
* second to match method-level annotations. The `scan` function walks
|
||||
* up from each matched annotation to find its enclosing class and
|
||||
* combines the prefix with the method path.
|
||||
* Every route-defining annotation (class/interface `@RequestMapping`
|
||||
* prefixes, `@FeignClient(path)` prefixes, `@(Get|...)Mapping` method
|
||||
* routes and native `@RequestLine`s) is matched by a single consolidated
|
||||
* query (`JAVA_ROUTE_ANNOTATION_PATTERNS`) in one pass via
|
||||
* `scanRouteAnnotations`. The `scan` function then walks up from each
|
||||
* matched method to its enclosing class/interface to combine the prefix
|
||||
* with the method path. Call-site consumers (RestTemplate, WebClient,
|
||||
* OkHttp, Java/Apache HttpClient) keep their own focused queries.
|
||||
*/
|
||||
|
||||
const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
||||
|
|
@ -37,20 +41,17 @@ const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
|||
PatchMapping: 'PATCH',
|
||||
};
|
||||
|
||||
// ─── Provider: Spring class-level @RequestMapping prefix ──────────────
|
||||
// Two patterns are needed because the AST shape differs depending on
|
||||
// whether the annotation uses a positional argument or a named one:
|
||||
// Each route-defining annotation has two AST shapes — a positional argument
|
||||
// and a named one — that must both be matched:
|
||||
// @RequestMapping("/api") → (annotation_argument_list (string_literal))
|
||||
// @RequestMapping(path = "/api") → (annotation_argument_list (element_value_pair key:(identifier) value:(string_literal)))
|
||||
// @RequestMapping(value = "/api") → same as above
|
||||
//
|
||||
// The named-argument pattern MUST constrain the `key` field to the route
|
||||
// member names (`path`/`value`); without it, the query also captures
|
||||
// non-route attributes such as `produces`, `consumes`, `headers`, `name`,
|
||||
// `params` (their right-hand string literals would be mis-extracted as
|
||||
// route prefixes — e.g. `produces = "application/json"` would corrupt
|
||||
// every method route under that controller). The sibling
|
||||
// `topic-patterns/java.ts` uses the same `key:` constraint approach.
|
||||
// For named arguments only the route member keys (`path`/`value`) carry a URL;
|
||||
// non-route attributes (`produces`, `consumes`, `headers`, `name`, `params`)
|
||||
// would otherwise be mis-extracted (e.g. `produces = "application/json"` would
|
||||
// corrupt every route). That key filtering is done in `isRouteMemberKey`, and
|
||||
// all of these annotations are matched by the one `JAVA_ROUTE_ANNOTATION_PATTERNS`
|
||||
// query below (see its header for why the filtering lives in JS, not the query).
|
||||
interface SpringRouteBinding {
|
||||
method: string;
|
||||
path: string;
|
||||
|
|
@ -71,9 +72,34 @@ interface SpringTypeInfo {
|
|||
methods: SpringMethodInfo[];
|
||||
}
|
||||
|
||||
// ─── Provider: Spring class/interface-level @RequestMapping prefix ───
|
||||
const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({
|
||||
name: 'java-spring-type-prefix',
|
||||
// ─── Route-defining annotations (one generic query, one pass) ─────────
|
||||
// Every Java route-mapper annotation shares one shape: an annotation carrying a
|
||||
// single string argument — positional `"..."` or named `key = "..."` — on a
|
||||
// class, interface, or method. This SINGLE query matches that shape generically;
|
||||
// `scanRouteAnnotations` then reads the annotation NAME (`@ann`) and declaration
|
||||
// kind (`@node.type`) in its for-loop to decide what each match means. Adding a
|
||||
// new framework annotation that follows this single-string-argument shape is a
|
||||
// change to that loop (and the lookup maps), not to this query. Annotations with
|
||||
// a different argument shape — e.g. an array value `@RequestMapping({"/a","/b"})`
|
||||
// — are out of scope here (as they were for the prior queries) and would need a
|
||||
// new branch.
|
||||
//
|
||||
// Captures (shared across all branches; intentionally framework-agnostic):
|
||||
// @ann → the annotation name identifier (RequestMapping, GetMapping, RequestLine, …)
|
||||
// @node → the enclosing declaration (class_declaration | interface_declaration | method_declaration)
|
||||
// @value → the string-literal argument
|
||||
// @key → the named-argument member key (absent for the positional shape)
|
||||
// @member → the method name (method_declaration branches only)
|
||||
//
|
||||
// The query carries NO `#eq?` / `#match?` predicates. Under the pinned
|
||||
// tree-sitter 0.21.x binding a top-level `[ ... ]` alternation compiles to one
|
||||
// pattern whose text predicates share a single bucket keyed by capture name, and
|
||||
// a `#match?` against a capture absent from the matched branch evaluates FALSE —
|
||||
// silently dropping sibling-branch matches. Keeping the query predicate-free
|
||||
// sidesteps that hazard entirely; all name/key discrimination lives in the
|
||||
// for-loop, where it reads as straight-line code.
|
||||
const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
|
||||
name: 'java-route-annotation',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
|
|
@ -83,36 +109,44 @@ const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({
|
|||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list (string_literal) @prefix)))) @type
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list (string_literal) @value)))) @node
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list (string_literal) @prefix)))) @type
|
||||
]
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
[
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list (string_literal) @value)))) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @prefix))))) @type
|
||||
key: (identifier) @key
|
||||
value: (string_literal) @value))))) @node
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @prefix))))) @type
|
||||
key: (identifier) @key
|
||||
value: (string_literal) @value))))) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list (string_literal) @value)))
|
||||
name: (identifier) @member) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: (string_literal) @value))))
|
||||
name: (identifier) @member) @node
|
||||
]
|
||||
`,
|
||||
},
|
||||
|
|
@ -135,89 +169,41 @@ const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({
|
|||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Consumer: OpenFeign interface-level prefixes ───────────────────
|
||||
// Feign's `name`/`value` attributes identify a service, not an HTTP path,
|
||||
// so only `path` is used as a URL prefix. `@RequestMapping` on a Feign
|
||||
// interface is also common and does carry a path prefix.
|
||||
const FEIGN_INTERFACE_PREFIX_PATTERNS = compilePatterns({
|
||||
name: 'java-feign-interface-prefix',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "FeignClient")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#eq? @key "path")
|
||||
value: (string_literal) @prefix))))) @interface
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list (string_literal) @prefix)))) @interface
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @prefix))))) @interface
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
// ─── Consumer: OpenFeign `@RequestLine("METHOD /path")` parsing ───────
|
||||
// OpenFeign's native annotation pairs an HTTP method and path in a single
|
||||
// string literal — see https://github.com/OpenFeign/feign#interface-annotations.
|
||||
// It is method-level only and is mutually exclusive with Spring MVC
|
||||
// `@GetMapping` / `@PostMapping` etc. on the same method (mixing them
|
||||
// requires a different Feign Contract — they are not combined). The match
|
||||
// itself comes from `JAVA_ROUTE_ANNOTATION_PATTERNS`; this regex splits the
|
||||
// verb from the path of the captured literal.
|
||||
//
|
||||
// Examples:
|
||||
// @RequestLine("GET /users/{id}")
|
||||
// @RequestLine("POST /users?status=active")
|
||||
const REQUEST_LINE_VERB_RE = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S.*?)\s*$/i;
|
||||
|
||||
// ─── Provider: Spring @(Get|Post|...)Mapping method annotations ───────
|
||||
// Same dual-pattern approach: positional vs named argument. The named
|
||||
// pattern restricts the annotation member name to `path`/`value` to
|
||||
// avoid capturing unrelated string-valued attributes
|
||||
// (`produces`, `consumes`, `headers`, `name`, `params`, ...).
|
||||
const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({
|
||||
name: 'java-spring-method-route',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")
|
||||
arguments: (annotation_argument_list (string_literal) @path)))
|
||||
name: (identifier) @method_name) @method
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @path))))
|
||||
name: (identifier) @method_name) @method
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
/**
|
||||
* Parse a Feign `@RequestLine` value into a method + path pair.
|
||||
*
|
||||
* `@RequestLine("METHOD /path[?query]")` packs both fields in one string;
|
||||
* the query portion is dropped because contract IDs are method+path only
|
||||
* (consistent with how other consumers like RestTemplate/WebClient drop
|
||||
* query strings when their values are inline literals).
|
||||
*
|
||||
* Returns null if the value is not a recognized HTTP verb followed by a
|
||||
* path beginning with `/`.
|
||||
*/
|
||||
function parseRequestLine(raw: string): { method: string; path: string } | null {
|
||||
const match = REQUEST_LINE_VERB_RE.exec(raw);
|
||||
if (!match) return null;
|
||||
const [, verb, rest] = match;
|
||||
if (typeof verb !== 'string' || typeof rest !== 'string') return null;
|
||||
const queryIdx = rest.indexOf('?');
|
||||
const pathOnly = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).trim();
|
||||
if (!pathOnly.startsWith('/')) return null;
|
||||
return { method: verb.toUpperCase(), path: pathOnly };
|
||||
}
|
||||
|
||||
// ─── Consumer: Spring RestTemplate (object-named + method-named) ──────
|
||||
// RestTemplate.getForObject / getForEntity → GET
|
||||
|
|
@ -433,34 +419,132 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[
|
|||
return false;
|
||||
}
|
||||
|
||||
function collectTypePrefixes(tree: Parser.Tree): Map<number, string> {
|
||||
const prefixByTypeId = new Map<number, string>();
|
||||
for (const match of runCompiledPatterns(SPRING_TYPE_PREFIX_PATTERNS, tree)) {
|
||||
const prefixNode = match.captures.prefix;
|
||||
const typeNode = match.captures.type;
|
||||
if (!prefixNode || !typeNode) continue;
|
||||
const prefix = unquoteLiteral(prefixNode.text);
|
||||
if (prefix !== null) prefixByTypeId.set(typeNode.id, prefix);
|
||||
}
|
||||
return prefixByTypeId;
|
||||
/**
|
||||
* A named annotation argument contributes a route only when its member key is
|
||||
* `path` or `value`; a positional argument (no key node) always qualifies.
|
||||
* This is the JS-side replacement for the in-query `^(path|value)$` filter and
|
||||
* drops Spring's non-route string attributes (`produces`, `consumes`,
|
||||
* `headers`, `name`, `params`) that would otherwise be mis-read as routes.
|
||||
*/
|
||||
function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined): boolean {
|
||||
if (!keyNode) return true;
|
||||
return keyNode.text === 'path' || keyNode.text === 'value';
|
||||
}
|
||||
|
||||
function collectMethodRoutes(tree: Parser.Tree): Map<number, SpringRouteBinding[]> {
|
||||
const routesByMethodId = new Map<number, SpringRouteBinding[]>();
|
||||
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
|
||||
const annNode = match.captures.ann;
|
||||
const pathNode = match.captures.path;
|
||||
const methodNode = match.captures.method;
|
||||
if (!annNode || !pathNode || !methodNode) continue;
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const rawPath = unquoteLiteral(pathNode.text);
|
||||
if (rawPath === null) continue;
|
||||
const routes = routesByMethodId.get(methodNode.id) ?? [];
|
||||
routes.push({ method: httpMethod, path: rawPath });
|
||||
routesByMethodId.set(methodNode.id, routes);
|
||||
interface MethodRouteAnnotation {
|
||||
methodNode: Parser.SyntaxNode;
|
||||
methodName: string | null;
|
||||
httpMethod: string;
|
||||
rawPath: string;
|
||||
}
|
||||
|
||||
interface RequestLineAnnotation {
|
||||
methodNode: Parser.SyntaxNode;
|
||||
methodName: string | null;
|
||||
parsed: { method: string; path: string };
|
||||
}
|
||||
|
||||
interface RouteAnnotationScan {
|
||||
/** Spring `@RequestMapping` URL prefix per class/interface node id (last write wins). */
|
||||
prefixByTypeId: Map<number, string>;
|
||||
/** OpenFeign interface prefix per interface node id; `@FeignClient(path)` wins over `@RequestMapping`. */
|
||||
feignPrefixByInterfaceId: Map<number, string>;
|
||||
/** One entry per resolved Spring `@(Get|...)Mapping` route — a method with N mappings yields N entries. */
|
||||
methodRoutes: MethodRouteAnnotation[];
|
||||
/** One entry per OpenFeign `@RequestLine` whose value parses to a verb + path. */
|
||||
requestLines: RequestLineAnnotation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every Java route-defining annotation in a single tree-sitter pass.
|
||||
*
|
||||
* The generic `JAVA_ROUTE_ANNOTATION_PATTERNS` query yields one match per
|
||||
* annotation-carrying-a-string-argument on any class / interface / method. This
|
||||
* loop reads the annotation name and declaration kind to decide what each match
|
||||
* means, ignoring annotations it does not recognise. The HTTP verb map
|
||||
* (`METHOD_ANNOTATION_TO_HTTP`) and the `path`/`value` key filter
|
||||
* (`isRouteMemberKey`) live here rather than in the query (see its header).
|
||||
*/
|
||||
function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
||||
const matches = runCompiledPatterns(JAVA_ROUTE_ANNOTATION_PATTERNS, tree);
|
||||
|
||||
// The two prefix maps intentionally diverge for the same interface node:
|
||||
// `prefixByTypeId` feeds the Spring *provider* path (class prefix +
|
||||
// collectSpringTypes cross-file inheritance), while `feignPrefixByInterfaceId`
|
||||
// feeds the OpenFeign *consumer* path in scan(). An interface carrying both
|
||||
// `@RequestMapping` and `@FeignClient(path)` lands a different value in each.
|
||||
const prefixByTypeId = new Map<number, string>();
|
||||
const feignPrefixByInterfaceId = new Map<number, string>();
|
||||
const methodRoutes: MethodRouteAnnotation[] = [];
|
||||
const requestLines: RequestLineAnnotation[] = [];
|
||||
// Interface `@RequestMapping` prefixes rank below `@FeignClient(path)`;
|
||||
// collect them and apply only after the FeignClient pass below.
|
||||
const interfaceRequestMappingPrefixes: Array<{ id: number; prefix: string }> = [];
|
||||
|
||||
for (const { captures } of matches) {
|
||||
const annNode = captures.ann;
|
||||
const node = captures.node;
|
||||
const valueNode = captures.value;
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
const ann = annNode.text;
|
||||
const keyNode = captures.key; // undefined for the positional shape
|
||||
|
||||
if (node.type === 'method_declaration') {
|
||||
// Method-level: a Spring `@(Get|...)Mapping` route, or native `@RequestLine`.
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[ann];
|
||||
if (httpMethod) {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
const rawPath = unquoteLiteral(valueNode.text);
|
||||
if (rawPath !== null) {
|
||||
methodRoutes.push({
|
||||
methodNode: node,
|
||||
methodName: captures.member?.text ?? null,
|
||||
httpMethod,
|
||||
rawPath,
|
||||
});
|
||||
}
|
||||
} else if (ann === 'RequestLine') {
|
||||
// Feign packs verb + path in one literal; its only named argument is `value`.
|
||||
if (keyNode && keyNode.text !== 'value') continue;
|
||||
const raw = unquoteLiteral(valueNode.text);
|
||||
const parsed = raw !== null ? parseRequestLine(raw) : null;
|
||||
if (parsed) {
|
||||
requestLines.push({
|
||||
methodNode: node,
|
||||
methodName: captures.member?.text ?? null,
|
||||
parsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type-level (class or interface): a Spring `@RequestMapping` URL prefix, or
|
||||
// — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix.
|
||||
if (ann === 'RequestMapping') {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
const prefix = unquoteLiteral(valueNode.text);
|
||||
if (prefix !== null) {
|
||||
prefixByTypeId.set(node.id, prefix);
|
||||
if (node.type === 'interface_declaration') {
|
||||
interfaceRequestMappingPrefixes.push({ id: node.id, prefix });
|
||||
}
|
||||
}
|
||||
} else if (ann === 'FeignClient' && node.type === 'interface_declaration') {
|
||||
// Feign's `name`/`value` identify a service, not a path — only `path` is a prefix.
|
||||
if (!keyNode || keyNode.text !== 'path') continue;
|
||||
const prefix = unquoteLiteral(valueNode.text);
|
||||
if (prefix !== null && !feignPrefixByInterfaceId.has(node.id)) {
|
||||
feignPrefixByInterfaceId.set(node.id, prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
return routesByMethodId;
|
||||
|
||||
for (const { id, prefix } of interfaceRequestMappingPrefixes) {
|
||||
if (!feignPrefixByInterfaceId.has(id)) feignPrefixByInterfaceId.set(id, prefix);
|
||||
}
|
||||
|
||||
return { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines };
|
||||
}
|
||||
|
||||
function collectDirectMethods(typeNode: Parser.SyntaxNode): Parser.SyntaxNode[] {
|
||||
|
|
@ -500,8 +584,13 @@ function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] {
|
|||
}
|
||||
|
||||
function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo[] {
|
||||
const prefixByTypeId = collectTypePrefixes(tree);
|
||||
const routesByMethodId = collectMethodRoutes(tree);
|
||||
const { prefixByTypeId, methodRoutes } = scanRouteAnnotations(tree);
|
||||
const routesByMethodId = new Map<number, SpringRouteBinding[]>();
|
||||
for (const route of methodRoutes) {
|
||||
const routes = routesByMethodId.get(route.methodNode.id) ?? [];
|
||||
routes.push({ method: route.httpMethod, path: route.rawPath });
|
||||
routesByMethodId.set(route.methodNode.id, routes);
|
||||
}
|
||||
const out: SpringTypeInfo[] = [];
|
||||
|
||||
for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) {
|
||||
|
|
@ -593,57 +682,62 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
scan(tree) {
|
||||
const out: HttpDetection[] = [];
|
||||
|
||||
// ─── Providers: Spring class prefix + method annotations ────────
|
||||
const prefixByTypeId = collectTypePrefixes(tree);
|
||||
// ─── Spring providers + OpenFeign consumers (one query pass) ────
|
||||
// `scanRouteAnnotations` resolves every route-defining annotation —
|
||||
// class/interface prefixes, method `@(Get|...)Mapping`s and native
|
||||
// `@RequestLine`s — from a single `matches()` pass over the tree.
|
||||
const { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines } =
|
||||
scanRouteAnnotations(tree);
|
||||
|
||||
const feignPrefixByInterfaceId = new Map<number, string>();
|
||||
for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) {
|
||||
const prefixNode = match.captures.prefix;
|
||||
const interfaceNode = match.captures.interface;
|
||||
if (!prefixNode || !interfaceNode) continue;
|
||||
const prefix = unquoteLiteral(prefixNode.text);
|
||||
if (prefix !== null && !feignPrefixByInterfaceId.has(interfaceNode.id))
|
||||
feignPrefixByInterfaceId.set(interfaceNode.id, prefix);
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
|
||||
const annNode = match.captures.ann;
|
||||
const pathNode = match.captures.path;
|
||||
const nameNode = match.captures.method_name;
|
||||
const methodNode = match.captures.method;
|
||||
if (!annNode || !pathNode || !methodNode) continue;
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const rawPath = unquoteLiteral(pathNode.text);
|
||||
if (rawPath === null) continue;
|
||||
const enclosingInterface = findEnclosingInterface(methodNode);
|
||||
// A `@(Get|...)Mapping` inside a `@FeignClient` interface is an OpenFeign
|
||||
// *consumer* (it describes a remote call); the same annotation inside a
|
||||
// class is a Spring *provider*. A mapping on a non-Feign interface has no
|
||||
// enclosing class and is dropped here — interface→controller inheritance is
|
||||
// handled by `scanProject`.
|
||||
for (const route of methodRoutes) {
|
||||
const enclosingInterface = findEnclosingInterface(route.methodNode);
|
||||
if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) {
|
||||
const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? '';
|
||||
const fullPath = joinPath(prefix, rawPath);
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'openfeign',
|
||||
method: httpMethod,
|
||||
path: fullPath,
|
||||
name: nameNode?.text ?? null,
|
||||
method: route.httpMethod,
|
||||
path: joinPath(prefix, route.rawPath),
|
||||
name: route.methodName,
|
||||
confidence: 0.7,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const enclosingClass = findEnclosingClass(methodNode);
|
||||
const enclosingClass = findEnclosingClass(route.methodNode);
|
||||
if (!enclosingClass) continue;
|
||||
const prefix = prefixByTypeId.get(enclosingClass.id) ?? '';
|
||||
const fullPath = joinPath(prefix, rawPath);
|
||||
out.push({
|
||||
role: 'provider',
|
||||
framework: 'spring',
|
||||
method: httpMethod,
|
||||
path: fullPath,
|
||||
name: nameNode?.text ?? null,
|
||||
method: route.httpMethod,
|
||||
path: joinPath(prefix, route.rawPath),
|
||||
name: route.methodName,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
// Native OpenFeign `@RequestLine("METHOD /path")`. Method-level only; the
|
||||
// enclosing interface MUST carry `@FeignClient`, otherwise the same
|
||||
// annotation name in unrelated libraries would be a false positive.
|
||||
for (const requestLine of requestLines) {
|
||||
const enclosingInterface = findEnclosingInterface(requestLine.methodNode);
|
||||
if (!enclosingInterface || !hasAnnotation(enclosingInterface, 'FeignClient')) continue;
|
||||
const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? '';
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'openfeign',
|
||||
method: requestLine.parsed.method,
|
||||
path: joinPath(prefix, requestLine.parsed.path),
|
||||
name: requestLine.methodName,
|
||||
confidence: 0.75,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Consumers: RestTemplate ────────────────────────────────────
|
||||
for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) {
|
||||
const methodNode = match.captures.method;
|
||||
|
|
|
|||
154
gitnexus/src/core/ingestion/csharp-namespace-gate.ts
Normal file
154
gitnexus/src/core/ingestion/csharp-namespace-gate.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Pure predicates gating C# `using` suffix-fallback resolution so BCL usings
|
||||
* (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
|
||||
* file (#1881).
|
||||
*
|
||||
* Lives in the shared `ingestion/` layer — NOT under `languages/csharp/` — so
|
||||
* BOTH the registry-primary scope resolver (`languages/csharp/import-target.ts`)
|
||||
* and the legacy DAG resolver (`import-resolvers/csharp.ts`) can import it
|
||||
* without an `import-resolvers/ -> languages/` dependency inversion (#5).
|
||||
*/
|
||||
|
||||
import type { CSharpNamespaceEvidence } from './language-config.js';
|
||||
|
||||
/**
|
||||
* Top-level namespace segments that clearly belong to the BCL / runtime / a
|
||||
* ubiquitous third-party package — i.e. roots a normal repo does NOT declare.
|
||||
* These stay gated even when the namespace scan is truncated, so a single
|
||||
* unreadable file / capped subtree can't silently re-enable BCL→local suffix
|
||||
* matches repo-wide (#1881). A repo that legitimately declares one of these
|
||||
* roots is still allowed via the alignment escape hatch below.
|
||||
*/
|
||||
const CSHARP_EXTERNAL_ROOTS: ReadonlySet<string> = new Set([
|
||||
// .NET BCL / runtime
|
||||
'System',
|
||||
'Microsoft',
|
||||
'Windows',
|
||||
'Mono',
|
||||
// ubiquitous third-party NuGet roots
|
||||
'Newtonsoft',
|
||||
'Serilog',
|
||||
'AutoMapper',
|
||||
'MediatR',
|
||||
'Polly',
|
||||
'FluentValidation',
|
||||
'Grpc',
|
||||
'Google',
|
||||
'Azure',
|
||||
'Amazon',
|
||||
'AWSSDK',
|
||||
// common test frameworks
|
||||
'Xunit',
|
||||
'NUnit',
|
||||
'Moq',
|
||||
'FluentAssertions',
|
||||
'NSubstitute',
|
||||
'Shouldly',
|
||||
]);
|
||||
|
||||
/** Whether `targetRaw`'s top-level segment is a clearly-external root. */
|
||||
function isExternalRoot(targetRaw: string): boolean {
|
||||
const dot = targetRaw.indexOf('.');
|
||||
const top = dot === -1 ? targetRaw : targetRaw.slice(0, dot);
|
||||
return CSHARP_EXTERNAL_ROOTS.has(top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the unanchored suffix fallback may run for `targetRaw`.
|
||||
*
|
||||
* Fails OPEN when the namespace scan was truncated (large repos must not
|
||||
* silently lose legitimate edges, #1881 #11) and when no evidence was
|
||||
* threaded at all (preserves legacy permissive behavior). The truncation
|
||||
* fail-open is carved out for clearly-external roots (BCL / well-known
|
||||
* packages) that the repo does not declare, so one incomplete scan can't
|
||||
* re-open the #1881 hole repo-wide. Otherwise defers to
|
||||
* {@link importAlignsWithDeclaredNamespaces}.
|
||||
*/
|
||||
export function csharpSuffixFallbackAllowed(
|
||||
targetRaw: string,
|
||||
evidence: CSharpNamespaceEvidence | undefined,
|
||||
): boolean {
|
||||
if (evidence === undefined) return true;
|
||||
if (evidence.truncated) {
|
||||
// Keep clearly-external roots blocked through truncation UNLESS the repo
|
||||
// actually declares an aligning namespace (the alignment check is the
|
||||
// escape hatch — a repo that declares `namespace System;` still resolves).
|
||||
if (
|
||||
isExternalRoot(targetRaw) &&
|
||||
!importAlignsWithDeclaredNamespaces(
|
||||
targetRaw,
|
||||
evidence.declaredNamespaces,
|
||||
evidence.rootNamespaces,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return importAlignsWithDeclaredNamespaces(
|
||||
targetRaw,
|
||||
evidence.declaredNamespaces,
|
||||
evidence.rootNamespaces,
|
||||
);
|
||||
}
|
||||
|
||||
/** True when `targetRaw` plausibly refers to a namespace declared in-repo. */
|
||||
export function importAlignsWithDeclaredNamespaces(
|
||||
targetRaw: string,
|
||||
declaredNamespaces: ReadonlySet<string> | undefined,
|
||||
rootNamespaces?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (declaredNamespaces === undefined || declaredNamespaces.size === 0) return false;
|
||||
|
||||
// Exact: the import IS a declared in-repo namespace.
|
||||
if (declaredNamespaces.has(targetRaw)) return true;
|
||||
|
||||
// Child-of: the import's IMMEDIATE parent namespace is declared in-repo.
|
||||
// Anchoring on the direct parent — not "any declared prefix" — is what stops
|
||||
// a declared BCL prefix from green-lighting an unrelated BCL using: a repo
|
||||
// that declares `namespace System;` must NOT make `using
|
||||
// System.Threading.Tasks;` resolve to a coincidental local `Tasks.cs`,
|
||||
// because the import's parent `System.Threading` is not itself declared
|
||||
// (#1881). The case this still allows is a type / `using static` import under
|
||||
// a declared namespace laid out without its full path on disk, e.g.
|
||||
// `using static MyApp.Utils.Logger;` when `MyApp.Utils` is declared.
|
||||
const lastDot = targetRaw.lastIndexOf('.');
|
||||
if (lastDot > 0 && declaredNamespaces.has(targetRaw.slice(0, lastDot))) return true;
|
||||
|
||||
// Ancestor-of: the import is a strict prefix of some declared namespace
|
||||
// (e.g. `using MyApp;` when `MyApp.Models` is declared). Only honored when
|
||||
// the import also sits at or above an in-repo root namespace, so a BCL prefix
|
||||
// can't qualify merely because a file declares something deeper under it
|
||||
// (e.g. `System.Threading.Tasks.Extensions`) (#1881).
|
||||
const childPrefix = targetRaw + '.';
|
||||
for (const ns of declaredNamespaces) {
|
||||
if (ns.startsWith(childPrefix)) {
|
||||
return isAtOrAboveInRepoRoot(targetRaw, declaredNamespaces, rootNamespaces);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAtOrAboveInRepoRoot(
|
||||
targetRaw: string,
|
||||
declaredNamespaces: ReadonlySet<string>,
|
||||
rootNamespaces: ReadonlySet<string> | undefined,
|
||||
): boolean {
|
||||
const descendantPrefix = targetRaw + '.';
|
||||
if (rootNamespaces !== undefined && rootNamespaces.size > 0) {
|
||||
for (const root of rootNamespaces) {
|
||||
// targetRaw equals a root, or is an ancestor of one (e.g. `using MyApp;`
|
||||
// for csproj RootNamespace `MyApp.Core`).
|
||||
if (root === targetRaw || root.startsWith(descendantPrefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// No explicit roots (e.g. no csproj): treat the top-level segment of each
|
||||
// declared namespace as the implied root.
|
||||
for (const ns of declaredNamespaces) {
|
||||
const dot = ns.indexOf('.');
|
||||
const top = dot === -1 ? ns : ns.slice(0, dot);
|
||||
if (top === targetRaw) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -7,27 +7,45 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js';
|
||||
import { createStandardStrategy } from '../standard.js';
|
||||
import { resolveCSharpImportInternal, resolveCSharpNamespaceDir } from '../csharp.js';
|
||||
import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js';
|
||||
|
||||
/** C# namespace-based resolution strategy via .csproj configs. */
|
||||
export const csharpNamespaceStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => {
|
||||
const csharpConfigs = ctx.configs.csharpConfigs;
|
||||
if (csharpConfigs.length > 0) {
|
||||
const resolvedFiles = resolveCSharpImportInternal(
|
||||
rawImportPath,
|
||||
csharpConfigs,
|
||||
ctx.normalizedFileList,
|
||||
ctx.allFileList,
|
||||
ctx.index,
|
||||
);
|
||||
if (resolvedFiles.length > 1) {
|
||||
const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
|
||||
if (dirSuffix) {
|
||||
return { kind: 'package', files: resolvedFiles, dirSuffix };
|
||||
}
|
||||
const evidence = ctx.configs.csharpNamespaces;
|
||||
if (csharpConfigs.length === 0) {
|
||||
// No csproj → there's no namespace→directory mapping to apply, so the
|
||||
// generic strategy would normally take over. But that generic suffix match
|
||||
// is UNGATED: it re-introduces the BCL→local spurious match the #1881 gate
|
||||
// exists to stop. Mirror the registry leg's no-csproj path — defer to the
|
||||
// generic strategy ONLY for imports that align with an in-repo declared
|
||||
// namespace; for everything else (BCL usings) return an authoritative empty
|
||||
// result that STOPS the chain (#2 parity). With no evidence threaded the
|
||||
// gate fails open, so behavior is unchanged when the scan didn't run.
|
||||
if (!csharpSuffixFallbackAllowed(rawImportPath, evidence)) {
|
||||
return { kind: 'files', files: [] };
|
||||
}
|
||||
if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles };
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
const resolvedFiles = resolveCSharpImportInternal(
|
||||
rawImportPath,
|
||||
csharpConfigs,
|
||||
ctx.normalizedFileList,
|
||||
ctx.allFileList,
|
||||
ctx.index,
|
||||
evidence,
|
||||
);
|
||||
if (resolvedFiles.length > 1) {
|
||||
const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
|
||||
if (dirSuffix) {
|
||||
return { kind: 'package', files: resolvedFiles, dirSuffix };
|
||||
}
|
||||
}
|
||||
// Authoritative once csproj configs exist: return even an empty result to
|
||||
// STOP the chain, so the generic suffix fallback can't re-introduce the
|
||||
// gated BCL→local match this resolver just suppressed (#1881).
|
||||
return { kind: 'files', files: resolvedFiles };
|
||||
};
|
||||
|
||||
export const csharpImportConfig: ImportResolutionConfig = {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,16 @@
|
|||
|
||||
import type { SuffixIndex } from './utils.js';
|
||||
import { suffixResolve } from './utils.js';
|
||||
import type { CSharpProjectConfig } from '../language-config.js';
|
||||
import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../language-config.js';
|
||||
import { csharpSuffixFallbackAllowed } from '../csharp-namespace-gate.js';
|
||||
|
||||
/**
|
||||
* Resolve a C# using-directive import path to matching .cs files (low-level helper).
|
||||
* Tries single-file match first, then directory match for namespace imports.
|
||||
*
|
||||
* The final unanchored suffix fallback is gated on `evidence` so BCL usings
|
||||
* (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
|
||||
* file (#1881). When `evidence` is omitted the fallback stays permissive.
|
||||
*/
|
||||
export function resolveCSharpImportInternal(
|
||||
importPath: string,
|
||||
|
|
@ -19,6 +24,7 @@ export function resolveCSharpImportInternal(
|
|||
normalizedFileList: string[],
|
||||
allFileList: string[],
|
||||
index?: SuffixIndex,
|
||||
evidence?: CSharpNamespaceEvidence,
|
||||
): string[] {
|
||||
const namespacePath = importPath.replace(/\./g, '/');
|
||||
const results: string[] = [];
|
||||
|
|
@ -86,7 +92,11 @@ export function resolveCSharpImportInternal(
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback: suffix matching without namespace stripping (single file)
|
||||
// Fallback: suffix matching without namespace stripping (single file).
|
||||
// Gated on in-repo declared-namespace evidence (#1881).
|
||||
if (!csharpSuffixFallbackAllowed(importPath, evidence)) {
|
||||
return [];
|
||||
}
|
||||
const pathParts = namespacePath.split('/').filter(Boolean);
|
||||
const fallback = suffixResolve(pathParts, normalizedFileList, allFileList, index);
|
||||
return fallback ? [fallback] : [];
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
TsconfigPaths,
|
||||
GoModuleConfig,
|
||||
CSharpProjectConfig,
|
||||
CSharpNamespaceEvidence,
|
||||
ComposerConfig,
|
||||
} from '../language-config.js';
|
||||
import type { SwiftPackageConfig } from '../language-config.js';
|
||||
|
|
@ -32,6 +33,8 @@ export interface ImportConfigs {
|
|||
composerConfig: ComposerConfig | null;
|
||||
swiftPackageConfig: SwiftPackageConfig | null;
|
||||
csharpConfigs: CSharpProjectConfig[];
|
||||
/** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */
|
||||
csharpNamespaces?: CSharpNamespaceEvidence;
|
||||
}
|
||||
|
||||
/** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import fs from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import { createInterface } from 'readline';
|
||||
import path from 'path';
|
||||
import type { ImportConfigs } from './import-resolvers/types.js';
|
||||
import type { CsharpStructureLineScanner } from './languages/csharp/namespace-siblings.js';
|
||||
|
||||
import { isDev } from './utils/env.js';
|
||||
|
||||
|
|
@ -40,6 +43,44 @@ export interface CSharpProjectConfig {
|
|||
projectDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared-namespace evidence used to gate C# suffix-fallback resolution so
|
||||
* BCL usings (e.g. `System.Threading.Tasks`) can't match a coincidentally-
|
||||
* named local file (#1881).
|
||||
*/
|
||||
export interface CSharpNamespaceEvidence {
|
||||
/** Every `namespace X.Y` declared in-repo (scan may be capped — see `truncated`). */
|
||||
readonly declaredNamespaces?: ReadonlySet<string>;
|
||||
/** csproj RootNamespace values plus the top-level segment of each declared
|
||||
* namespace — the anchor set for the parent-namespace gate direction. */
|
||||
readonly rootNamespaces?: ReadonlySet<string>;
|
||||
/** True when the BFS hit its dir/depth cap, so the namespace set may be
|
||||
* incomplete; the gate fails open (allows) in that case. */
|
||||
readonly truncated?: boolean;
|
||||
}
|
||||
|
||||
/** Result of a single BFS over a repo collecting both csproj configs and
|
||||
* declared `.cs` namespaces (one disk traversal — see `scanCSharpProject`). */
|
||||
export interface CSharpProjectScan {
|
||||
readonly configs: CSharpProjectConfig[];
|
||||
readonly declaredNamespaces: ReadonlySet<string>;
|
||||
readonly rootNamespaces: ReadonlySet<string>;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
/** Project the one-pass {@link CSharpProjectScan} into the
|
||||
* {@link CSharpNamespaceEvidence} both import-resolution legs thread to the
|
||||
* #1881 gate — one shape, two carriers (`ImportConfigs.csharpNamespaces` for
|
||||
* the legacy DAG, `CsharpResolutionConfig.namespaces` for the scope resolver).
|
||||
* Keeps the field mapping in one place so the two carriers can't drift. */
|
||||
export function csharpScanToEvidence(scan: CSharpProjectScan): CSharpNamespaceEvidence {
|
||||
return {
|
||||
declaredNamespaces: scan.declaredNamespaces,
|
||||
rootNamespaces: scan.rootNamespaces,
|
||||
truncated: scan.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Swift Package Manager module config */
|
||||
export interface SwiftPackageConfig {
|
||||
/** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
|
||||
|
|
@ -141,58 +182,258 @@ export async function loadComposerConfig(repoRoot: string): Promise<ComposerConf
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse .csproj files to extract RootNamespace.
|
||||
* Scans the repo root for .csproj files and returns configs for each.
|
||||
*/
|
||||
export async function loadCSharpProjectConfig(repoRoot: string): Promise<CSharpProjectConfig[]> {
|
||||
const configs: CSharpProjectConfig[] = [];
|
||||
// BFS scan for .csproj files up to 5 levels deep, cap at 100 dirs to avoid runaway scanning
|
||||
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
const maxDepth = 5;
|
||||
const maxDirs = 100;
|
||||
let dirsScanned = 0;
|
||||
// BFS bounds shared by the C# project/namespace scan. Sized to comfortably
|
||||
// exceed normal C# repos so `truncated` stays the rare exception it was meant
|
||||
// to be: a too-low cap trips `truncated=true` on ordinary repos, which makes
|
||||
// `csharpSuffixFallbackAllowed` fail OPEN for every import and silently
|
||||
// disables the #1881 gate. Truncation remains the safety valve for genuinely
|
||||
// pathological trees (deep generated output, huge monorepos).
|
||||
const CSHARP_SCAN_MAX_DEPTH = 24;
|
||||
const CSHARP_SCAN_MAX_DIRS = 20000;
|
||||
// Bound on in-flight file reads per directory so a directory with thousands of
|
||||
// `.cs` files can't exhaust file descriptors / spike memory. Mirrors the
|
||||
// Phase-1 walker's `READ_CONCURRENCY` (see `filesystem-walker.ts`).
|
||||
const CSHARP_SCAN_READ_CONCURRENCY = 32;
|
||||
const CSHARP_SCAN_SKIP_DIRS = new Set(['node_modules', '.git', 'bin', 'obj']);
|
||||
const CSHARP_ROOT_NAMESPACE_RE = /<RootNamespace>\s*([^<]+)\s*<\/RootNamespace>/;
|
||||
|
||||
while (scanQueue.length > 0 && dirsScanned < maxDirs) {
|
||||
// Declared `namespace` names are extracted with the comment/string-aware
|
||||
// scanner shared with the scope-resolution namespace-siblings pass
|
||||
// (`extractCsharpStructureViaScanner`), not a bare regex: a regex matches
|
||||
// `namespace` inside comments and string literals, seeding the #1881 gate
|
||||
// with phantom namespaces. Imported lazily (and memoized) so the always-on
|
||||
// `loadImportConfigs` path — every repo, every language — doesn't eagerly
|
||||
// pull tree-sitter-c-sharp in via `namespace-siblings.ts` → `query.ts`.
|
||||
let csharpScannerFactoryPromise: Promise<() => CsharpStructureLineScanner> | undefined;
|
||||
function getCsharpStructureScannerFactory(): Promise<() => CsharpStructureLineScanner> {
|
||||
if (csharpScannerFactoryPromise === undefined) {
|
||||
csharpScannerFactoryPromise = import('./languages/csharp/namespace-siblings.js').then(
|
||||
(mod) => mod.createCsharpStructureScanner,
|
||||
);
|
||||
}
|
||||
return csharpScannerFactoryPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single BFS over a repo that collects BOTH .csproj configs and the set of
|
||||
* `namespace` declarations from `.cs` files.
|
||||
*
|
||||
* The csproj walk is cheap (a handful of project files); the namespace scan
|
||||
* is NOT — it opens and reads every `.cs` file in the repo to collect its
|
||||
* `namespace` declarations. That `.cs` read cost is the price of the #1881
|
||||
* gate, not a saving: collapsing the csproj and namespace walks into one BFS
|
||||
* avoids a second directory traversal, but the per-file `.cs` reads are new
|
||||
* work this scan introduces. Reads within a directory are issued in bounded
|
||||
* windows (see below); directories are still visited breadth-first.
|
||||
*/
|
||||
export async function scanCSharpProject(repoRoot: string): Promise<CSharpProjectScan> {
|
||||
const configs: CSharpProjectConfig[] = [];
|
||||
const declaredNamespaces = new Set<string>();
|
||||
const rootNamespaces = new Set<string>();
|
||||
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
let dirsScanned = 0;
|
||||
let truncated = false;
|
||||
|
||||
while (scanQueue.length > 0) {
|
||||
if (dirsScanned >= CSHARP_SCAN_MAX_DIRS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const { dir, depth } = scanQueue.shift()!;
|
||||
dirsScanned++;
|
||||
let entries: import('fs').Dirent[];
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && depth < maxDepth) {
|
||||
// Skip common non-project directories
|
||||
if (
|
||||
entry.name === 'node_modules' ||
|
||||
entry.name === '.git' ||
|
||||
entry.name === 'bin' ||
|
||||
entry.name === 'obj'
|
||||
)
|
||||
continue;
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Unreadable directory → its `.cs` namespaces are missed, so the scan is
|
||||
// incomplete. Mark truncated so the #1881 gate fails OPEN (allows the
|
||||
// suffix fallback) rather than wrongly blocking an import whose declaring
|
||||
// namespace lived in the unread subtree (#5).
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
// Collect read targets, then issue them in bounded windows (rather than all
|
||||
// at once) so a directory with thousands of `.cs` files can't exhaust file
|
||||
// descriptors / spike memory. csproj reads keep entry order (config
|
||||
// precedence matters); `.cs` namespace results land in shared Sets where
|
||||
// order is irrelevant.
|
||||
const csprojNames: string[] = [];
|
||||
const csNames: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (CSHARP_SCAN_SKIP_DIRS.has(entry.name)) continue;
|
||||
if (depth < CSHARP_SCAN_MAX_DEPTH) {
|
||||
scanQueue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
|
||||
} else {
|
||||
truncated = true; // a real subtree was pruned at the depth cap
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith('.csproj')) {
|
||||
try {
|
||||
const csprojPath = path.join(dir, entry.name);
|
||||
const content = await fs.readFile(csprojPath, 'utf-8');
|
||||
const nsMatch = content.match(/<RootNamespace>\s*([^<]+)\s*<\/RootNamespace>/);
|
||||
const rootNamespace = nsMatch ? nsMatch[1].trim() : entry.name.replace(/\.csproj$/, '');
|
||||
const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
|
||||
configs.push({ rootNamespace, projectDir });
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 Loaded C# project: ${entry.name} (namespace: ${rootNamespace}, dir: ${projectDir})`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Can't read .csproj
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (entry.name.endsWith('.csproj')) {
|
||||
csprojNames.push(entry.name);
|
||||
} else if (entry.name.endsWith('.cs')) {
|
||||
csNames.push(entry.name);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < csprojNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) {
|
||||
const batch = csprojNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY);
|
||||
const settled = await Promise.allSettled(
|
||||
batch.map((name) => readCsprojConfig(path.join(dir, name), name, repoRoot, dir)),
|
||||
);
|
||||
for (const r of settled) {
|
||||
const config = r.status === 'fulfilled' ? r.value : null;
|
||||
if (config) {
|
||||
configs.push(config);
|
||||
rootNamespaces.add(config.rootNamespace);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't read directory
|
||||
}
|
||||
for (let i = 0; i < csNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) {
|
||||
const batch = csNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY);
|
||||
const settled = await Promise.allSettled(
|
||||
batch.map((name) =>
|
||||
collectDeclaredNamespaces(path.join(dir, name), declaredNamespaces, rootNamespaces),
|
||||
),
|
||||
);
|
||||
// A `.cs` that was unreadable (or whose read/scan unexpectedly rejected)
|
||||
// leaves its namespaces uncollected → mark truncated to fail the #1881
|
||||
// gate OPEN rather than wrongly suppress an import. The scan streams each
|
||||
// file, so file size no longer trips truncation.
|
||||
for (const r of settled) {
|
||||
if (r.status !== 'fulfilled' || r.value === 'truncated') truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return configs;
|
||||
|
||||
if (truncated) {
|
||||
// Surface the fail-open so an incomplete scan (dir/depth cap, or an
|
||||
// unreadable directory or `.cs` file) silently disabling the #1881 gate
|
||||
// repo-wide is observable (#4) rather than a mystery edge regression.
|
||||
logger.warn(
|
||||
`[csharp] namespace scan of ${repoRoot} truncated (dir cap ${CSHARP_SCAN_MAX_DIRS}, depth cap ${CSHARP_SCAN_MAX_DEPTH}, an unreadable directory, or an unreadable .cs file); the #1881 suffix-fallback gate fails open for unmatched usings`,
|
||||
);
|
||||
}
|
||||
return { configs, declaredNamespaces, rootNamespaces, truncated };
|
||||
}
|
||||
|
||||
// Generous soft budget for locating `<RootNamespace>`: a real .csproj declares
|
||||
// it in the first PropertyGroup near the top, so this is only reached by a
|
||||
// pathological project file with a huge leading ItemGroup and no early
|
||||
// RootNamespace. On hit we OMIT the config rather than guess a root (Codex F4).
|
||||
const CSPROJ_ROOT_SCAN_MAX_BYTES = 4 * 1024 * 1024;
|
||||
// Overlap kept across stream chunks so a `<RootNamespace>` tag straddling a
|
||||
// chunk boundary is still matched (the tag + a short namespace value fit well
|
||||
// within this window).
|
||||
const CSPROJ_TAG_OVERLAP = 512;
|
||||
|
||||
/**
|
||||
* Stream a `.csproj` just far enough to find `<RootNamespace>`, in constant
|
||||
* memory and without a stat-then-read filesystem race. Returns the namespace
|
||||
* when found; otherwise `rootNamespace: null` with `capHit` distinguishing a
|
||||
* genuine read-to-EOF absence (`false`) from "not found within the soft budget"
|
||||
* (`true`) — so the caller never synthesizes a wrong filename root for a late
|
||||
* tag (Codex F4).
|
||||
*/
|
||||
async function findCsprojRootNamespace(
|
||||
csprojPath: string,
|
||||
): Promise<{ rootNamespace: string | null; capHit: boolean }> {
|
||||
const stream = createReadStream(csprojPath, { encoding: 'utf-8' });
|
||||
let window = '';
|
||||
let bytesRead = 0;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
const text = chunk as string;
|
||||
bytesRead += text.length;
|
||||
window =
|
||||
(window.length > CSPROJ_TAG_OVERLAP ? window.slice(-CSPROJ_TAG_OVERLAP) : window) + text;
|
||||
const match = window.match(CSHARP_ROOT_NAMESPACE_RE);
|
||||
if (match) {
|
||||
stream.destroy();
|
||||
return { rootNamespace: match[1]!.trim(), capHit: false };
|
||||
}
|
||||
if (bytesRead >= CSPROJ_ROOT_SCAN_MAX_BYTES) {
|
||||
stream.destroy();
|
||||
return { rootNamespace: null, capHit: true };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Unreadable .csproj: don't guess a filename root either — omit the config.
|
||||
return { rootNamespace: null, capHit: true };
|
||||
}
|
||||
return { rootNamespace: null, capHit: false }; // read to EOF, tag genuinely absent
|
||||
}
|
||||
|
||||
async function readCsprojConfig(
|
||||
csprojPath: string,
|
||||
fileName: string,
|
||||
repoRoot: string,
|
||||
dir: string,
|
||||
): Promise<CSharpProjectConfig | null> {
|
||||
const { rootNamespace: found, capHit } = await findCsprojRootNamespace(csprojPath);
|
||||
// A late `<RootNamespace>` we couldn't reach (capHit) or an unreadable file
|
||||
// must NOT synthesize a filename root — a wrong authoritative root would make
|
||||
// imports under the real root resolve to nothing and suppress the fallback
|
||||
// (Codex F4). Omit the config so the no-csproj fallback stays available. Only
|
||||
// fall back to the filename on a genuine read-to-EOF absence of the tag.
|
||||
if (capHit) return null;
|
||||
const rootNamespace = found ?? fileName.replace(/\.csproj$/, '');
|
||||
const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 Loaded C# project: ${fileName} (namespace: ${rootNamespace}, dir: ${projectDir})`,
|
||||
);
|
||||
}
|
||||
return { rootNamespace, projectDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one `.cs` file line-by-line and collect its declared `namespace` names
|
||||
* into the shared Sets.
|
||||
*
|
||||
* Streaming (rather than reading the whole file into a string) keeps memory
|
||||
* constant regardless of file size, so a large generated `.cs` (`*.g.cs`, EF /
|
||||
* gRPC output) is fully scanned instead of skipped by a per-file size cap —
|
||||
* which would otherwise trip `truncated` and disable the #1881 gate repo-wide.
|
||||
* Only the cheap line scan streams here; the tree-sitter PARSE path keeps its
|
||||
* own size cap.
|
||||
*
|
||||
* Returns `'truncated'` when the file could not be read, so the caller marks the
|
||||
* scan truncated and the #1881 gate fails OPEN rather than wrongly suppress an
|
||||
* import declared in the unread file. Returns `'ok'` on a complete read.
|
||||
*/
|
||||
async function collectDeclaredNamespaces(
|
||||
filePath: string,
|
||||
declaredNamespaces: Set<string>,
|
||||
rootNamespaces: Set<string>,
|
||||
): Promise<'ok' | 'truncated'> {
|
||||
const createScanner = await getCsharpStructureScannerFactory();
|
||||
const scanner = createScanner();
|
||||
try {
|
||||
// `crlfDelay: Infinity` treats every `\r\n` as a single break; the line
|
||||
// scanner is terminator-agnostic, so a streamed scan yields the same
|
||||
// namespaces as scanning the whole file content at once.
|
||||
const lines = createInterface({
|
||||
input: createReadStream(filePath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
for await (const line of lines) {
|
||||
scanner.pushLine(line);
|
||||
}
|
||||
} catch {
|
||||
return 'truncated'; // unreadable source → signal truncation (fail open)
|
||||
}
|
||||
const structure = scanner.result();
|
||||
for (const ns of structure.namespaces) {
|
||||
declaredNamespaces.add(ns);
|
||||
const dot = ns.indexOf('.');
|
||||
rootNamespaces.add(dot === -1 ? ns : ns.slice(0, dot));
|
||||
}
|
||||
// A declaration the scanner could not fully capture (Codex F3) means the
|
||||
// collected namespaces are an incomplete picture of this file — treat it like
|
||||
// a truncated read so the #1881 gate fails OPEN rather than over-block an
|
||||
// import whose namespace was dropped.
|
||||
return structure.incomplete ? 'truncated' : 'ok';
|
||||
}
|
||||
|
||||
export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
|
||||
|
|
@ -231,11 +472,13 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
|
|||
|
||||
/** Load all language-specific configs once for an ingestion run. */
|
||||
export async function loadImportConfigs(repoRoot: string): Promise<ImportConfigs> {
|
||||
const csharpScan = await scanCSharpProject(repoRoot);
|
||||
return {
|
||||
tsconfigPaths: await loadTsconfigPaths(repoRoot),
|
||||
goModule: await loadGoModulePath(repoRoot),
|
||||
composerConfig: await loadComposerConfig(repoRoot),
|
||||
swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
|
||||
csharpConfigs: await loadCSharpProjectConfig(repoRoot),
|
||||
csharpConfigs: csharpScan.configs,
|
||||
csharpNamespaces: csharpScanToEvidence(csharpScan),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,30 +9,112 @@
|
|||
* match. Cross-file partial-class aggregation runs at graph-bridge
|
||||
* time (Unit 6) via `populateOwners`.
|
||||
*
|
||||
* The legacy csproj-based `resolveCSharpImportInternal` needs config
|
||||
* objects the scope-resolver doesn't carry; the Unit 7 parity gate
|
||||
* will surface cases where the suffix-match diverges from the
|
||||
* namespace-based resolver and we'll adjust the contract if needed.
|
||||
* When `.csproj` configs are available, consults the legacy
|
||||
* namespace-directory resolver first. Both that resolver's suffix
|
||||
* fallback and the progressive prefix stripping below are gated on
|
||||
* declared in-repo namespaces so BCL usings like `System.Threading.Tasks`
|
||||
* cannot spuriously match a local `Tasks.cs` (#1881).
|
||||
*
|
||||
* Returning `null` lets the finalize algorithm mark the edge as
|
||||
* `linkStatus: 'unresolved'`.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../../language-config.js';
|
||||
import { resolveCSharpImportInternal } from '../../import-resolvers/csharp.js';
|
||||
import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js';
|
||||
import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js';
|
||||
|
||||
export interface CsharpResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
readonly csharpConfigs?: readonly CSharpProjectConfig[];
|
||||
readonly namespaces?: CSharpNamespaceEvidence;
|
||||
}
|
||||
|
||||
/** Normalized file list + suffix index, built once per workspace `allFilePaths`. */
|
||||
interface WorkspaceFileIndex {
|
||||
readonly normalized: string[];
|
||||
readonly all: string[];
|
||||
readonly index: SuffixIndex;
|
||||
}
|
||||
|
||||
// Memoize on Set identity: the orchestrator passes the SAME `allFilePaths`
|
||||
// Set through every `resolveImportTarget` call in a pass, so this rebuilds
|
||||
// the normalized list + suffix index once instead of once per import (#1881 #2).
|
||||
const workspaceFileIndexCache = new WeakMap<ReadonlySet<string>, WorkspaceFileIndex>();
|
||||
|
||||
function getWorkspaceFileIndex(allFilePaths: ReadonlySet<string>): WorkspaceFileIndex {
|
||||
const cached = workspaceFileIndexCache.get(allFilePaths);
|
||||
if (cached) return cached;
|
||||
const all = [...allFilePaths];
|
||||
const normalized = all.map((f) => f.replace(/\\/g, '/'));
|
||||
const built: WorkspaceFileIndex = { normalized, all, index: buildSuffixIndex(normalized, all) };
|
||||
workspaceFileIndexCache.set(allFilePaths, built);
|
||||
return built;
|
||||
}
|
||||
|
||||
export function resolveCsharpImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
// WorkspaceIndex is `unknown` in the shared contract (Ring 1
|
||||
// placeholder). The scope-resolution orchestrator hands us a
|
||||
// CsharpResolveContext-shaped object; narrow structurally rather
|
||||
// than via a cast chain so unexpected shapes return null cleanly.
|
||||
const ctx = narrowContext(workspaceIndex);
|
||||
if (ctx === null) return null;
|
||||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
const targetRaw = parsedImport.targetRaw;
|
||||
const evidence = ctx.namespaces;
|
||||
|
||||
const csharpConfigs = ctx.csharpConfigs ?? [];
|
||||
if (csharpConfigs.length > 0) {
|
||||
const { normalized, all, index } = getWorkspaceFileIndex(ctx.allFilePaths);
|
||||
const fromCsproj = resolveCSharpImportInternal(
|
||||
targetRaw,
|
||||
[...csharpConfigs],
|
||||
normalized,
|
||||
all,
|
||||
index,
|
||||
evidence,
|
||||
);
|
||||
if (fromCsproj.length > 0) return fromCsproj[0]!;
|
||||
// csproj configs are authoritative: mirror legacy `configs/csharp.ts`,
|
||||
// which returns an empty result to STOP the chain. Falling through to the
|
||||
// ungated `resolveDirectMatch` would re-introduce the BCL→local match the
|
||||
// internal resolver's gate just suppressed (#1881 parity, #2).
|
||||
return null;
|
||||
}
|
||||
|
||||
// Namespace path: `System.Collections.Generic` → `System/Collections/Generic`.
|
||||
const pathLike = targetRaw.replace(/\./g, '/');
|
||||
|
||||
// Gate the WHOLE no-csproj path on declared in-repo namespaces — the direct
|
||||
// path/suffix match INCLUDED — so a BCL using can't resolve to a
|
||||
// coincidentally path-aligned local file (e.g. `Legacy/System/Threading/
|
||||
// Tasks.cs` satisfying `using System.Threading.Tasks;`). Running the gate
|
||||
// before `resolveDirectMatch` mirrors the legacy leg's gate-first ordering
|
||||
// (`import-resolvers/configs/csharp.ts`), so the two legs are equivalent
|
||||
// (#1881 parity, Codex F2). The gate keeps its fail-open for
|
||||
// undefined/truncated evidence, so legitimate edges in unscanned repos are
|
||||
// unaffected.
|
||||
if (!csharpSuffixFallbackAllowed(targetRaw, evidence)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Exact file / nested-suffix / namespace-dir direct-child match.
|
||||
const direct = resolveDirectMatch(ctx.allFilePaths, pathLike);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
// Progressive prefix stripping — mirrors csproj's root-namespace mapping
|
||||
// without the csproj.
|
||||
return resolveByProgressiveStripping(ctx.allFilePaths, pathLike);
|
||||
}
|
||||
|
||||
/**
|
||||
* `WorkspaceIndex` is an opaque `unknown` placeholder in the shared contract;
|
||||
* the orchestrator hands us a `CsharpResolveContext`-shaped object. Narrow
|
||||
* structurally rather than via a cast chain so unexpected shapes fail cleanly.
|
||||
*/
|
||||
function narrowContext(workspaceIndex: WorkspaceIndex): CsharpResolveContext | null {
|
||||
const ctx = workspaceIndex as CsharpResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
|
|
@ -41,90 +123,78 @@ export function resolveCsharpImportTarget(
|
|||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Namespace path: `System.Collections.Generic` → `System/Collections/Generic`.
|
||||
const pathLike = parsedImport.targetRaw.replace(/\./g, '/');
|
||||
const suffix = `/${pathLike}`;
|
||||
|
||||
// Exact file match: `System/Collections/Generic.cs` (rare but legal).
|
||||
// Suffix match for nested layouts: `src/lib/System/Collections/Generic.cs`.
|
||||
// Directory match: first `.cs` file directly inside the namespace dir
|
||||
// (e.g. `System/Collections/Generic/List.cs` matches namespace Generic).
|
||||
let exactFile: string | null = null;
|
||||
/**
|
||||
* First-pass resolution against the full namespace path:
|
||||
* exact whole-path file > nested suffix file > first `.cs` directly inside
|
||||
* the namespace directory.
|
||||
*/
|
||||
function resolveDirectMatch(allFilePaths: ReadonlySet<string>, pathLike: string): string | null {
|
||||
const exactName = `${pathLike}.cs`;
|
||||
const nestedSuffix = `/${exactName}`;
|
||||
let suffixFile: string | null = null;
|
||||
let directoryChild: string | null = null;
|
||||
const dirPrefix = `${pathLike}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
|
||||
for (const raw of ctx.allFilePaths) {
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.cs')) continue;
|
||||
if (f === `${pathLike}.cs`) {
|
||||
exactFile = raw;
|
||||
break;
|
||||
}
|
||||
if (suffixFile === null && f.endsWith(`${suffix}.cs`)) {
|
||||
suffixFile = raw;
|
||||
}
|
||||
if (directoryChild === null) {
|
||||
// Namespace-to-directory match: pick the first `.cs` directly in
|
||||
// the namespace dir (not nested deeper). Legacy resolver emits
|
||||
// all of them; we take one so the scope-resolver contract stays
|
||||
// single-target.
|
||||
const atRoot = f.startsWith(dirPrefix);
|
||||
const atNested = f.includes(suffixDirPrefix);
|
||||
if (atRoot || atNested) {
|
||||
const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1;
|
||||
const after = f.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) {
|
||||
directoryChild = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (f === exactName) return raw; // exact whole-path match wins
|
||||
if (suffixFile === null && f.endsWith(nestedSuffix)) suffixFile = raw;
|
||||
}
|
||||
|
||||
if (exactFile !== null) return exactFile;
|
||||
if (suffixFile !== null) return suffixFile;
|
||||
if (directoryChild !== null) return directoryChild;
|
||||
return findDirectChild(allFilePaths, pathLike);
|
||||
}
|
||||
|
||||
// Progressive prefix stripping — mirrors csproj's root-namespace
|
||||
// mapping without the csproj. `using CrossFile.Models;` in a repo
|
||||
// laid out `Models/User.cs` (no `CrossFile/` prefix) works because
|
||||
// the legacy resolver consults csproj; the scope-resolver layer
|
||||
// doesn't have csproj, so we try each suffix of the namespace path
|
||||
// against `.cs` files and directories.
|
||||
//
|
||||
// Also handles `using static CrossFile.Models.UserFactory;` —
|
||||
// strip the leading segment, try `Models/UserFactory.cs`; strip
|
||||
// two, try `UserFactory.cs`.
|
||||
/**
|
||||
* First `.cs` file that lives directly inside the namespace directory
|
||||
* `dirSegment` (at repo root or nested under a project prefix), not deeper.
|
||||
* The legacy resolver emits all of them; the scope-resolver contract is
|
||||
* single-target so we take one.
|
||||
*/
|
||||
function findDirectChild(allFilePaths: ReadonlySet<string>, dirSegment: string): string | null {
|
||||
const dirPrefix = `${dirSegment}/`;
|
||||
const nestedDirPrefix = `/${dirPrefix}`;
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.cs')) continue;
|
||||
const atRoot = f.startsWith(dirPrefix);
|
||||
const atNested = f.includes(nestedDirPrefix);
|
||||
if (!atRoot && !atNested) continue;
|
||||
const idx = atRoot ? 0 : f.indexOf(nestedDirPrefix) + 1;
|
||||
const after = f.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try each suffix of the namespace path against `.cs` files and directories,
|
||||
* stripping leading segments one at a time. Models `using CrossFile.Models;`
|
||||
* resolving to `Models/User.cs` in a repo laid out without the `CrossFile/`
|
||||
* prefix (the scope-resolver layer has no csproj to consult).
|
||||
*/
|
||||
function resolveByProgressiveStripping(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
const segments = pathLike.split('/').filter(Boolean);
|
||||
for (let skip = 1; skip < segments.length; skip++) {
|
||||
const tail = segments.slice(skip).join('/');
|
||||
if (tail === '') continue;
|
||||
const tailFile = `${tail}.cs`;
|
||||
const tailSuffix = `/${tailFile}`;
|
||||
const tailDir = `${tail}/`;
|
||||
const tailSuffixDir = `/${tailDir}`;
|
||||
let tailDirectChild: string | null = null;
|
||||
for (const raw of ctx.allFilePaths) {
|
||||
let tailFileMatch: string | null = null;
|
||||
for (const raw of allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.cs')) continue;
|
||||
if (f === tailFile) return raw;
|
||||
if (f.endsWith(tailSuffix)) return raw;
|
||||
if (tailDirectChild === null) {
|
||||
const atRoot = f.startsWith(tailDir);
|
||||
const atNested = f.includes(tailSuffixDir);
|
||||
if (atRoot || atNested) {
|
||||
const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1;
|
||||
const after = f.slice(idx + tailDir.length);
|
||||
if (after.length > 0 && !after.includes('/')) tailDirectChild = raw;
|
||||
}
|
||||
if (f === tailFile || f.endsWith(tailSuffix)) {
|
||||
tailFileMatch = raw;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tailDirectChild !== null) return tailDirectChild;
|
||||
if (tailFileMatch !== null) return tailFileMatch;
|
||||
const child = findDirectChild(allFilePaths, tail);
|
||||
if (child !== null) return child;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,19 +48,62 @@ export interface CsharpFileStructure {
|
|||
/** Dotted paths from `using static X.Y.Z;` (including
|
||||
* `global using static` and aliased `using static A = X.Y.Z;`). */
|
||||
readonly usingStaticPaths: readonly string[];
|
||||
/** True when the scanner saw a `namespace` / `using static` declaration it
|
||||
* could not fully capture (keyword not at line start, split across lines, or
|
||||
* an unparseable identifier form). Callers feeding the #1881 gate must treat
|
||||
* this like a truncated scan and fail OPEN, since a dropped namespace would
|
||||
* otherwise over-block a legitimate import (Codex F3). Absent/false on a
|
||||
* cleanly-scanned file. */
|
||||
readonly incomplete?: boolean;
|
||||
}
|
||||
|
||||
// A dotted C# namespace identifier: each segment is an optional verbatim `@`
|
||||
// followed by a Unicode letter/`_` and Unicode letters/digits/`_`. The `u` flag
|
||||
// makes the classes Unicode-aware so `namespace Café.Models;` is captured (the
|
||||
// old ASCII `[A-Za-z…]` truncated it). The `@` markers are stripped from the
|
||||
// capture so it matches the tree-sitter AST's `name` text.
|
||||
const CS_NS_IDENT = String.raw`@?[\p{L}_][\p{L}\p{N}_]*(?:\.@?[\p{L}_][\p{L}\p{N}_]*)*`;
|
||||
|
||||
// Line-anchored matchers for the worker-path fallback (see
|
||||
// `extractCsharpStructureViaScanner`). Anchored at line start (after
|
||||
// indentation); the scanner additionally tracks block-comment / string
|
||||
// state across lines so a keyword at the start of a line inside one of
|
||||
// those regions is skipped.
|
||||
const CS_NAMESPACE_RE = /^[ \t]*namespace[ \t]+([A-Za-z_@][A-Za-z0-9_.]*)/;
|
||||
const CS_NAMESPACE_RE = new RegExp(String.raw`^[ \t]*namespace[ \t]+(${CS_NS_IDENT})`, 'u');
|
||||
// `global using static`, plain `using static`, and the aliased
|
||||
// `using static Alias = NS.Type;` form (the AST keeps the RHS path, so
|
||||
// the optional `Alias =` is skipped and only the dotted path captured).
|
||||
const CS_USING_STATIC_RE =
|
||||
/^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:[A-Za-z_@][A-Za-z0-9_]*[ \t]*=[ \t]*)?([A-Za-z_@][A-Za-z0-9_.]*)/;
|
||||
const CS_USING_STATIC_RE = new RegExp(
|
||||
String.raw`^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:@?[\p{L}_][\p{L}\p{N}_]*[ \t]*=[ \t]*)?(${CS_NS_IDENT})`,
|
||||
'u',
|
||||
);
|
||||
|
||||
// Incompleteness detectors — used ONLY when the precise matchers above failed,
|
||||
// to flag a declaration the scanner could not capture (so the file fails the
|
||||
// #1881 gate OPEN instead of silently dropping the namespace). Kept
|
||||
// high-precision so ordinary files never trip them (which would wrongly disable
|
||||
// the gate repo-wide):
|
||||
// - `…_BARE`: the keyword alone on a line (the name is on the next line).
|
||||
// - `…_AT_START`: a line-start declaration the precise matcher couldn't parse.
|
||||
// - `CS_NAMESPACE_AFTER_CODE`: a `namespace` keyword right after a `}`/`;`/`{`/`]`
|
||||
// (real code, NOT a `//` comment), i.e. not at line start.
|
||||
const CS_NAMESPACE_BARE = /^[ \t]*namespace[ \t]*\r?$/;
|
||||
const CS_USING_STATIC_BARE = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]*\r?$/;
|
||||
const CS_NAMESPACE_AT_START = /^[ \t]*namespace[ \t]+\S/;
|
||||
const CS_USING_STATIC_AT_START = /^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+\S/;
|
||||
const CS_NAMESPACE_AFTER_CODE = /[}\];{][ \t]*namespace[ \t]+@?[\p{L}_]/u;
|
||||
|
||||
/** Whether a `code`-state line declares a namespace / using-static the precise
|
||||
* matchers could not capture — see the detectors above. */
|
||||
function looksLikeUncapturedDeclaration(line: string): boolean {
|
||||
return (
|
||||
CS_NAMESPACE_BARE.test(line) ||
|
||||
CS_USING_STATIC_BARE.test(line) ||
|
||||
CS_NAMESPACE_AT_START.test(line) ||
|
||||
CS_USING_STATIC_AT_START.test(line) ||
|
||||
CS_NAMESPACE_AFTER_CODE.test(line)
|
||||
);
|
||||
}
|
||||
|
||||
/** Multi-line lexical state carried line-to-line by the scanner. */
|
||||
type CsScanState = 'code' | 'block' | 'verbatim' | 'raw';
|
||||
|
|
@ -182,26 +225,60 @@ function advanceCsScanState(
|
|||
* AST is a declaration whose keyword is not at the start of a code line
|
||||
* (split across lines, or sharing a line with a comment/string closer).
|
||||
* Mirrors PHP's `extractNamespaceViaScanner` (issue #1741). */
|
||||
export function extractCsharpStructureViaScanner(content: string): CsharpFileStructure {
|
||||
/** Incremental form of {@link extractCsharpStructureViaScanner}: feed lines one
|
||||
* at a time via `pushLine` (in source order), then read the accumulated
|
||||
* structure with `result()`. Lets a caller stream a file off disk
|
||||
* (`createReadStream` + `readline`) and scan it for `namespace` / `using
|
||||
* static` declarations in CONSTANT memory rather than buffering the whole file
|
||||
* into a string — the line splitting and per-line matching are identical, so a
|
||||
* streamed scan yields the same result as scanning the full content. The line
|
||||
* terminator must be stripped (as `readline` does, or `String.split('\n')`); a
|
||||
* trailing `\r` on a CRLF line is inert to both the matchers and the lexer. */
|
||||
export interface CsharpStructureLineScanner {
|
||||
pushLine(line: string): void;
|
||||
result(): CsharpFileStructure;
|
||||
}
|
||||
|
||||
/** Create a fresh stateful line scanner — see {@link CsharpStructureLineScanner}. */
|
||||
export function createCsharpStructureScanner(): CsharpStructureLineScanner {
|
||||
const namespaces: string[] = [];
|
||||
const usingStaticPaths: string[] = [];
|
||||
let incomplete = false;
|
||||
let state: CsScanState = 'code';
|
||||
let rawFence = 0;
|
||||
for (const line of content.split('\n')) {
|
||||
// Only match when the line START is real code — keywords reached while
|
||||
// inside a block comment / multi-line string are skipped.
|
||||
if (state === 'code') {
|
||||
const ns = CS_NAMESPACE_RE.exec(line);
|
||||
if (ns !== null) {
|
||||
namespaces.push(ns[1]!);
|
||||
} else {
|
||||
const us = CS_USING_STATIC_RE.exec(line);
|
||||
if (us !== null) usingStaticPaths.push(us[1]!);
|
||||
return {
|
||||
pushLine(line: string): void {
|
||||
// Only match when the line START is real code — keywords reached while
|
||||
// inside a block comment / multi-line string are skipped.
|
||||
if (state === 'code') {
|
||||
const ns = CS_NAMESPACE_RE.exec(line);
|
||||
if (ns !== null) {
|
||||
namespaces.push(ns[1]!.replace(/@/g, ''));
|
||||
} else {
|
||||
const us = CS_USING_STATIC_RE.exec(line);
|
||||
if (us !== null) {
|
||||
usingStaticPaths.push(us[1]!.replace(/@/g, ''));
|
||||
} else if (looksLikeUncapturedDeclaration(line)) {
|
||||
// A declaration the precise matchers couldn't capture → mark the
|
||||
// file incomplete so the #1881 gate fails OPEN (Codex F3).
|
||||
incomplete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[state, rawFence] = advanceCsScanState(line, state, rawFence);
|
||||
}
|
||||
return { namespaces, usingStaticPaths };
|
||||
[state, rawFence] = advanceCsScanState(line, state, rawFence);
|
||||
},
|
||||
result(): CsharpFileStructure {
|
||||
return incomplete
|
||||
? { namespaces, usingStaticPaths, incomplete }
|
||||
: { namespaces, usingStaticPaths };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function extractCsharpStructureViaScanner(content: string): CsharpFileStructure {
|
||||
const scanner = createCsharpStructureScanner();
|
||||
for (const line of content.split('\n')) scanner.pushLine(line);
|
||||
return scanner.result();
|
||||
}
|
||||
|
||||
/** Build a structural view of a C# file. Prefers `cachedTree` (handed in
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Per-workspace config for C# scope-resolution import targeting.
|
||||
*
|
||||
* Loaded once per analyze pass via `csharpScopeResolver.loadResolutionConfig`
|
||||
* and threaded into `resolveCsharpImportTarget`. The pure gate predicates live
|
||||
* in `../../csharp-namespace-gate.ts` (shared with the legacy DAG resolver).
|
||||
*/
|
||||
|
||||
import {
|
||||
scanCSharpProject,
|
||||
csharpScanToEvidence,
|
||||
type CSharpProjectConfig,
|
||||
type CSharpNamespaceEvidence,
|
||||
} from '../../language-config.js';
|
||||
|
||||
export interface CsharpResolutionConfig {
|
||||
readonly csharpConfigs: readonly CSharpProjectConfig[];
|
||||
/** In-repo declared-namespace evidence gating suffix-fallback resolution (#1881). */
|
||||
readonly namespaces?: CSharpNamespaceEvidence;
|
||||
}
|
||||
|
||||
export async function loadCsharpResolutionConfig(
|
||||
repoRoot: string,
|
||||
): Promise<CsharpResolutionConfig> {
|
||||
const scan = await scanCSharpProject(repoRoot);
|
||||
return {
|
||||
csharpConfigs: scan.configs,
|
||||
namespaces: csharpScanToEvidence(scan),
|
||||
};
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type CsharpResolveContext,
|
||||
} from './index.js';
|
||||
import { populateCsharpNamespaceSiblings } from './namespace-siblings.js';
|
||||
import { loadCsharpResolutionConfig, type CsharpResolutionConfig } from './resolution-config.js';
|
||||
import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js';
|
||||
|
||||
const csharpScopeResolver: ScopeResolver = {
|
||||
|
|
@ -26,8 +27,16 @@ const csharpScopeResolver: ScopeResolver = {
|
|||
languageProvider: csharpProvider,
|
||||
importEdgeReason: 'csharp-scope: using',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
const ws: CsharpResolveContext = { fromFile, allFilePaths };
|
||||
loadResolutionConfig: (repoPath) => loadCsharpResolutionConfig(repoPath),
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
|
||||
const config = resolutionConfig as CsharpResolutionConfig | undefined;
|
||||
const ws: CsharpResolveContext = {
|
||||
fromFile,
|
||||
allFilePaths,
|
||||
csharpConfigs: config?.csharpConfigs,
|
||||
namespaces: config?.namespaces,
|
||||
};
|
||||
// `WorkspaceIndex` is an opaque `unknown` placeholder in the
|
||||
// shared contract, so `ws` passes structurally without a cast.
|
||||
return resolveCsharpImportTarget(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
// On-disk path (Legacy/System/Threading/Tasks.cs) path-aligns with
|
||||
// `using System.Threading.Tasks;` but declares an UNRELATED in-repo namespace,
|
||||
// so the only way an IMPORTS edge forms is the coincidental path — which the
|
||||
// gate must block in the no-csproj path on BOTH legs (#1881, Codex F2).
|
||||
namespace MyApp.Legacy;
|
||||
|
||||
public class Tasks
|
||||
{
|
||||
public void Run() { }
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Models/User.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace MyApp.Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges-no-csproj/Services/OrderService.cs
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using System.Threading.Tasks;
|
||||
using MyApp.Models;
|
||||
|
||||
namespace MyApp.Services;
|
||||
|
||||
public class OrderService
|
||||
{
|
||||
public Task ProcessAsync()
|
||||
{
|
||||
var user = new User();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Legacy/Tasks.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace MyApp.Legacy;
|
||||
|
||||
public class Tasks
|
||||
{
|
||||
public void Run() { }
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Models/User.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace MyApp.Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Services/OrderService.cs
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using System.Threading.Tasks;
|
||||
using MyApp.Models;
|
||||
|
||||
namespace MyApp.Services;
|
||||
|
||||
public class OrderService
|
||||
{
|
||||
public Task ProcessAsync()
|
||||
{
|
||||
var user = new User();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-spurious-edges/Spurious.csproj
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>MyApp</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* C#: heritage resolution via base_list + ambiguous namespace-import refusal
|
||||
*/
|
||||
import { describe, expect, beforeAll } from 'vitest';
|
||||
import { describe, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
|
|
@ -2603,3 +2603,158 @@ describe('C# namespace-as-root with no trailing newline (issue #1086)', () => {
|
|||
expect(edge!.rel.reason).toBe('csharp-scope: using');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spurious IMPORTS: BCL usings must not match coincidentally-named local files
|
||||
// (#1881)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C# spurious import edges (#1881)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-spurious-edges'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('does not emit IMPORTS from System.Threading.Tasks to a local Tasks.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const spurious = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Legacy/Tasks.cs',
|
||||
);
|
||||
expect(spurious).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => {
|
||||
// Guards against the negative above passing vacuously: the fixture's
|
||||
// `using MyApp.Models;` must resolve to a real IMPORTS edge.
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
expect(imports.length).toBeGreaterThan(0);
|
||||
const legit = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs',
|
||||
);
|
||||
expect(legit).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1881 on the LEGACY DAG leg, forced in-process so it runs under `npm test`
|
||||
// (not only the CI parity matrix). `isRegistryPrimary` reads `process.env`
|
||||
// per call with no caching, so stubbing the flag before the pipeline run
|
||||
// routes C# import resolution through `csharpNamespaceStrategy` (#8).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C# spurious import edges — legacy DAG leg (#1881, #8)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '0');
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-spurious-edges'), () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('does not emit IMPORTS from System.Threading.Tasks to a local Tasks.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const spurious = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Legacy/Tasks.cs',
|
||||
);
|
||||
expect(spurious).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const legit = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs',
|
||||
);
|
||||
expect(legit).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1881 / Codex F2: in the NO-CSPROJ path the registry leg ran an ungated
|
||||
// direct-match before the gate, so a path-aligned `Legacy/System/Threading/
|
||||
// Tasks.cs` satisfied `using System.Threading.Tasks;`. Both legs must now block
|
||||
// it (gate-first), proving the legs are equivalent. Fixture ships NO .csproj.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C# spurious import edges — no-csproj direct-match, registry leg (#1881, Codex F2)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Pin to the registry leg: only progressive stripping resolves a no-csproj
|
||||
// namespace import, so the legit-edge assertion below is registry-specific.
|
||||
// Pinning also keeps this deterministic under the parity matrix's legacy run.
|
||||
vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '1');
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'csharp-spurious-edges-no-csproj'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('does not emit IMPORTS from System.Threading.Tasks to a path-aligned Legacy/System/Threading/Tasks.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const spurious = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' &&
|
||||
e.targetFilePath === 'Legacy/System/Threading/Tasks.cs',
|
||||
);
|
||||
expect(spurious).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still emits the legitimate in-repo edge OrderService.cs -> Models/User.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
expect(imports.length).toBeGreaterThan(0);
|
||||
const legit = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' && e.targetFilePath === 'Models/User.cs',
|
||||
);
|
||||
expect(legit).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('C# spurious import edges — no-csproj direct-match, legacy DAG leg (#1881, Codex F2, #8)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv('REGISTRY_PRIMARY_CSHARP', '0');
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'csharp-spurious-edges-no-csproj'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('does not emit IMPORTS from System.Threading.Tasks to a path-aligned Legacy/System/Threading/Tasks.cs', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const spurious = imports.find(
|
||||
(e) =>
|
||||
e.sourceFilePath === 'Services/OrderService.cs' &&
|
||||
e.targetFilePath === 'Legacy/System/Threading/Tasks.cs',
|
||||
);
|
||||
expect(spurious).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ingested the fixture so the absence of the spurious edge is meaningful (anti-vacuity)', () => {
|
||||
// The legacy DAG leg cannot resolve a no-csproj namespace import to a file
|
||||
// (`using MyApp.Models;` targets a directory of types — only the registry
|
||||
// leg's progressive stripping resolves it without a csproj RootNamespace, a
|
||||
// known registry-superiority gap). So the anti-vacuity guard here asserts
|
||||
// the three fixture files were ingested as graph nodes, proving the spurious
|
||||
// edge is absent because the gate blocked it — not because nothing parsed.
|
||||
const files = getNodesByLabel(result, 'File');
|
||||
expect(files.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -96,4 +96,84 @@ describe('extractCsharpStructureViaScanner', () => {
|
|||
const src = `/* header */ class C {}\nnamespace App.Real;`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']);
|
||||
});
|
||||
|
||||
// --- Unicode / @-verbatim identifiers (Codex F3): these must be CAPTURED, not
|
||||
// truncated/dropped, so the #1881 gate doesn't over-block legitimate imports.
|
||||
it('captures a Unicode namespace identifier', () => {
|
||||
const out = extractCsharpStructureViaScanner('namespace Café.Modèles;');
|
||||
expect(out.namespaces).toEqual(['Café.Modèles']);
|
||||
expect(out.incomplete).toBeFalsy();
|
||||
});
|
||||
|
||||
it('captures a non-Latin (Greek) namespace identifier', () => {
|
||||
expect(extractCsharpStructureViaScanner('namespace Ωμέγα.Models;').namespaces).toEqual([
|
||||
'Ωμέγα.Models',
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips a leading @ from a verbatim namespace identifier to match the AST', () => {
|
||||
expect(extractCsharpStructureViaScanner('namespace @namespace.Models;').namespaces).toEqual([
|
||||
'namespace.Models',
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips a mid-path @ from a verbatim namespace segment', () => {
|
||||
expect(extractCsharpStructureViaScanner('namespace App.@class.Models;').namespaces).toEqual([
|
||||
'App.class.Models',
|
||||
]);
|
||||
});
|
||||
|
||||
// --- Forms the line scanner cannot capture must flag `incomplete` so the
|
||||
// caller fails the #1881 gate OPEN (Codex F3) instead of dropping the namespace.
|
||||
it('flags `incomplete` for a namespace declaration split across lines', () => {
|
||||
const out = extractCsharpStructureViaScanner('namespace\n App.Models;');
|
||||
expect(out.namespaces).toEqual([]);
|
||||
expect(out.incomplete).toBe(true);
|
||||
});
|
||||
|
||||
it('flags `incomplete` for a namespace keyword not at line start', () => {
|
||||
const out = extractCsharpStructureViaScanner('class C {} namespace App.Models;');
|
||||
expect(out.incomplete).toBe(true);
|
||||
});
|
||||
|
||||
it('flags `incomplete` for an attributed same-line namespace', () => {
|
||||
const out = extractCsharpStructureViaScanner('[Obsolete] namespace App.Legacy;');
|
||||
expect(out.incomplete).toBe(true);
|
||||
});
|
||||
|
||||
// --- Guards: ordinary / handled forms must NEVER set `incomplete`, or one
|
||||
// exotic line would wrongly disable the gate repo-wide.
|
||||
it('does NOT flag `incomplete` for ordinary handled forms', () => {
|
||||
for (const src of [
|
||||
'namespace App.Models;',
|
||||
'namespace App.Services\n{\n}',
|
||||
'namespace A.One {}\nnamespace A.Two {}',
|
||||
'using static System.Math;\nnamespace App;',
|
||||
'global using static App.Utils.Logger;',
|
||||
'using static M = App.Utils.MathUtils;',
|
||||
'using System.Collections.Generic;\nusing App.Models;',
|
||||
'\t\tnamespace App.Indented;',
|
||||
'public class Global {}',
|
||||
'',
|
||||
]) {
|
||||
expect(extractCsharpStructureViaScanner(src).incomplete).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT flag `incomplete` for a `// namespace` line comment or a namespace mentioned after `//`', () => {
|
||||
expect(
|
||||
extractCsharpStructureViaScanner('// namespace Fake.Comment;\nnamespace App.Real;')
|
||||
.incomplete,
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
extractCsharpStructureViaScanner('public class C {} // namespace Foo').incomplete,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does NOT flag `incomplete` for an identifier that merely starts with "namespace"', () => {
|
||||
// `namespaceManager` is an ordinary identifier, not the keyword.
|
||||
expect(
|
||||
extractCsharpStructureViaScanner('var namespaceManager = Get();').incomplete,
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1135,6 +1135,37 @@ export const authClient = new authProto.auth.v1.AuthService(
|
|||
expect(consumers[0].contractId).toBe('grpc::auth.v1.AuthService/*');
|
||||
});
|
||||
|
||||
it('test_extract_ts_qualified_ctor_without_loadPackageDefinition_is_ignored', async () => {
|
||||
// Regression: an unrelated `obj.method(...)` member call must not trip the
|
||||
// loadPackageDefinition gate. With no loadPackageDefinition call present, a
|
||||
// qualified `new pkg...Service(...)` constructor must NOT become a consumer.
|
||||
// Pre-fix, the gate's shared-capture `function: [...]` alternation matched
|
||||
// every member call, so this spuriously emitted an AuthService consumer.
|
||||
writeFile(
|
||||
'proto/auth.proto',
|
||||
`syntax = "proto3";
|
||||
package auth.v1;
|
||||
service AuthService {
|
||||
rpc Login (LoginRequest) returns (LoginResponse);
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/auth.client.ts',
|
||||
`import * as grpc from '@grpc/grpc-js';
|
||||
|
||||
logger.info('starting up');
|
||||
export const authClient = new authProto.auth.v1.AuthService(
|
||||
'localhost:50051',
|
||||
grpc.credentials.createInsecure(),
|
||||
);`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('test_extract_ts_duplicate_consumer_patterns_in_one_file_dedupes_deterministically', async () => {
|
||||
writeFile(
|
||||
'proto/auth.proto',
|
||||
|
|
|
|||
|
|
@ -1774,6 +1774,254 @@ interface PrecedenceClient {
|
|||
expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts native @RequestLine consumers on @FeignClient interfaces', async () => {
|
||||
const dir = path.join(tmpDir, 'java-feign-request-line-basic');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'AiClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "ai-backend")
|
||||
interface AiClient {
|
||||
@RequestLine("POST /ai/summarize")
|
||||
String summarize();
|
||||
|
||||
@RequestLine("GET /ai/health")
|
||||
String health();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::POST::/ai/summarize' &&
|
||||
c.meta.framework === 'openfeign' &&
|
||||
c.confidence === 0.75,
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::GET::/ai/health' &&
|
||||
c.meta.framework === 'openfeign' &&
|
||||
c.confidence === 0.75,
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('joins @FeignClient(path=...) prefix with @RequestLine paths', async () => {
|
||||
const dir = path.join(tmpDir, 'java-feign-request-line-prefix');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'OrderClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "order-service", path = "/api")
|
||||
interface OrderClient {
|
||||
@RequestLine("GET /orders/{id}")
|
||||
OrderDto get(Long id);
|
||||
|
||||
@RequestLine("DELETE /orders/{id}")
|
||||
void delete(Long id);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('strips query strings from @RequestLine values when forming contract IDs', async () => {
|
||||
const dir = path.join(tmpDir, 'java-feign-request-line-query');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'SearchClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "search-service")
|
||||
interface SearchClient {
|
||||
@RequestLine("GET /search?q={query}&limit={limit}")
|
||||
SearchResult search();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
// Query string is dropped — contract ID is method+path only.
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/search')).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId.includes('?') || c.contractId.includes('limit')),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores @RequestLine on interfaces without @FeignClient', async () => {
|
||||
const dir = path.join(tmpDir, 'java-request-line-no-feign');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'PlainInterface.java'),
|
||||
`
|
||||
import feign.RequestLine;
|
||||
|
||||
interface PlainInterface {
|
||||
@RequestLine("GET /not-a-feign-client")
|
||||
String shouldNotBeExtracted();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/not-a-feign-client'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mixes @RequestLine and @GetMapping methods on the same @FeignClient interface', async () => {
|
||||
const dir = path.join(tmpDir, 'java-feign-mixed-annotations');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'MixedClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "mixed-service", path = "/api")
|
||||
interface MixedClient {
|
||||
@GetMapping("/spring-style")
|
||||
String springStyle();
|
||||
|
||||
@RequestLine("GET /native-style")
|
||||
String nativeStyle();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
// Both annotation styles produce contracts — they don't conflict.
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::GET::/api/spring-style' &&
|
||||
c.meta.framework === 'openfeign' &&
|
||||
c.confidence === 0.7,
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::GET::/api/native-style' &&
|
||||
c.meta.framework === 'openfeign' &&
|
||||
c.confidence === 0.75,
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('extracts @RequestLine values written with the named "value" argument', async () => {
|
||||
const dir = path.join(tmpDir, 'java-feign-request-line-named');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'NamedArgClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "named-arg-service")
|
||||
interface NamedArgClient {
|
||||
@RequestLine(value = "POST /create")
|
||||
String create();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) => c.contractId === 'http::POST::/create' && c.meta.framework === 'openfeign',
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('ignores @RequestLine whose named argument is not "value"', async () => {
|
||||
// The consolidated query matches every named annotation argument; the
|
||||
// scanRouteAnnotations loop drops a @RequestLine whose key is not `value`.
|
||||
const dir = path.join(tmpDir, 'java-feign-request-line-wrong-key');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'WrongKeyClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import feign.RequestLine;
|
||||
|
||||
@FeignClient(name = "wrong-key-service")
|
||||
interface WrongKeyClient {
|
||||
@RequestLine(name = "GET /should-not-extract")
|
||||
String shouldNotBeExtracted();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/should-not-extract'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers @FeignClient(path=...) over @RequestMapping when @RequestMapping appears first', async () => {
|
||||
// Reverse-order companion to the precedence test above: @FeignClient(path)
|
||||
// must win even when @RequestMapping is the first annotation in source,
|
||||
// exercising the deferred interfaceRequestMappingPrefixes apply.
|
||||
const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence-reversed');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'ReversedPrecedenceClient.java'),
|
||||
`
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@RequestMapping("/rm-path")
|
||||
@FeignClient(name = "order-service", path = "/feign-path")
|
||||
interface ReversedPrecedenceClient {
|
||||
@GetMapping("/orders")
|
||||
OrderDto getOrders();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/feign-path/orders')).toBeDefined();
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts Java and Apache HttpClient literal request construction', async () => {
|
||||
const dir = path.join(tmpDir, 'java-http-client-consumer');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -352,6 +352,65 @@ describe('csharpNamespaceStrategy', () => {
|
|||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('no csproj + non-aligned BCL import: stops the chain instead of the ungated standard strategy (#2)', () => {
|
||||
// Parity with the registry leg's no-csproj path. Without csproj configs the
|
||||
// generic strategy would suffix-match `System.Threading.Tasks` onto the
|
||||
// coincidental local `Legacy/Tasks.cs`. The gate sees the import aligns
|
||||
// with no declared namespace, so the strategy returns an absorbing sentinel
|
||||
// (`{ kind: 'files', files: [] }`) that STOPS the chain — the standard
|
||||
// strategy never runs and no spurious edge is emitted.
|
||||
const ctx = makeCtx(['Services/OrderService.cs', 'Legacy/Tasks.cs'], {
|
||||
csharpNamespaces: {
|
||||
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const result = csharpNamespaceStrategy(
|
||||
'System.Threading.Tasks',
|
||||
'Services/OrderService.cs',
|
||||
ctx,
|
||||
);
|
||||
expect(result).toEqual({ kind: 'files', files: [] });
|
||||
});
|
||||
|
||||
it('no csproj + in-repo-aligned import: keeps delegating to the standard strategy (#2)', () => {
|
||||
// An import that DOES align with a declared namespace must keep returning
|
||||
// null so the generic strategy resolves it — legitimate no-csproj behavior
|
||||
// is unchanged; only non-aligned (BCL) imports are stopped.
|
||||
const ctx = makeCtx(['Services/OrderService.cs', 'Models/User.cs'], {
|
||||
csharpNamespaces: {
|
||||
declaredNamespaces: new Set(['MyApp.Models', 'MyApp.Services']),
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const result = csharpNamespaceStrategy('MyApp.Models', 'Services/OrderService.cs', ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns an empty files result (chain-stop) for a gated BCL import when csproj configs exist (#1881, #8)', () => {
|
||||
// Legacy DAG leg of #1881: with csproj configs present, a BCL using like
|
||||
// `System.Threading.Tasks` must NOT suffix-match the coincidental local
|
||||
// `Legacy/Tasks.cs`. The strategy returns `{ kind: 'files', files: [] }`
|
||||
// (absorbing sentinel) to STOP the chain, NOT null — null would let the
|
||||
// generic suffix fallback re-introduce the spurious edge.
|
||||
const ctx = makeCtx(['Services/OrderService.cs', 'Legacy/Tasks.cs'], {
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
csharpNamespaces: {
|
||||
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const result = csharpNamespaceStrategy(
|
||||
'System.Threading.Tasks',
|
||||
'Services/OrderService.cs',
|
||||
ctx,
|
||||
);
|
||||
expect(result).toEqual({ kind: 'files', files: [] });
|
||||
});
|
||||
|
||||
it('csharpImportConfig full chain produces package-kind (strategy-order guard)', () => {
|
||||
const files = ['src/Services/Auth/AuthService.cs', 'src/Services/Auth/TokenService.cs'];
|
||||
const ctx = makeCtx(files, {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,20 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { promises as fsp } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { emitCsharpScopeCaptures } from '../../../../src/core/ingestion/languages/csharp/captures.js';
|
||||
import { interpretCsharpImport } from '../../../../src/core/ingestion/languages/csharp/interpret.js';
|
||||
import { resolveCsharpImportTarget } from '../../../../src/core/ingestion/languages/csharp/import-target.js';
|
||||
import { loadCsharpResolutionConfig } from '../../../../src/core/ingestion/languages/csharp/resolution-config.js';
|
||||
import { getMaxFileSizeBytes } from '../../../../src/core/ingestion/utils/max-file-size.js';
|
||||
import {
|
||||
csharpSuffixFallbackAllowed,
|
||||
importAlignsWithDeclaredNamespaces,
|
||||
} from '../../../../src/core/ingestion/csharp-namespace-gate.js';
|
||||
import { csharpScopeResolver } from '../../../../src/core/ingestion/languages/csharp/scope-resolver.js';
|
||||
import type { CSharpProjectConfig } from '../../../../src/core/ingestion/language-config.js';
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
|
||||
function importsFor(src: string): ParsedImport[] {
|
||||
|
|
@ -105,8 +116,32 @@ describe('interpretCsharpImport — using flavors', () => {
|
|||
});
|
||||
|
||||
describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
||||
function ctx(fromFile: string, paths: string[]): WorkspaceIndex {
|
||||
return { fromFile, allFilePaths: new Set(paths) } as unknown as WorkspaceIndex;
|
||||
function ctx(
|
||||
fromFile: string,
|
||||
paths: string[],
|
||||
declaredNamespaces?: ReadonlySet<string>,
|
||||
extra?: {
|
||||
rootNamespaces?: ReadonlySet<string>;
|
||||
truncated?: boolean;
|
||||
csharpConfigs?: readonly CSharpProjectConfig[];
|
||||
},
|
||||
): WorkspaceIndex {
|
||||
const hasEvidence =
|
||||
declaredNamespaces !== undefined ||
|
||||
extra?.rootNamespaces !== undefined ||
|
||||
extra?.truncated !== undefined;
|
||||
return {
|
||||
fromFile,
|
||||
allFilePaths: new Set(paths),
|
||||
csharpConfigs: extra?.csharpConfigs,
|
||||
namespaces: hasEvidence
|
||||
? {
|
||||
declaredNamespaces,
|
||||
rootNamespaces: extra?.rootNamespaces,
|
||||
truncated: extra?.truncated,
|
||||
}
|
||||
: undefined,
|
||||
} as unknown as WorkspaceIndex;
|
||||
}
|
||||
|
||||
it('resolves `MyApp.Services` to `MyApp/Services/...cs` when a direct child exists', () => {
|
||||
|
|
@ -174,4 +209,582 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
|||
} as unknown as WorkspaceIndex);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('does not map BCL usings to coincidentally-named local files (#1881)', () => {
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Tasks',
|
||||
importedName: 'System.Threading.Tasks',
|
||||
targetRaw: 'System.Threading.Tasks',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Tasks.cs', 'Events/OrderCreatedEvent.cs'],
|
||||
new Set(['MyApp.Services', 'MyApp.Events', 'MyApp.Legacy']),
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('does not map a BCL using to a coincidentally PATH-ALIGNED local file via direct-match (#1881, Codex F2)', () => {
|
||||
// The no-csproj direct-match must be gated too: `Legacy/System/Threading/
|
||||
// Tasks.cs` path-aligns with `using System.Threading.Tasks;` and would
|
||||
// satisfy resolveDirectMatch's nested-suffix match — but System.* is not a
|
||||
// declared in-repo namespace, so the gate (now run FIRST) blocks it.
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Tasks',
|
||||
importedName: 'System.Threading.Tasks',
|
||||
targetRaw: 'System.Threading.Tasks',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Legacy/System/Threading/Tasks.cs', 'Models/User.cs'],
|
||||
new Set(['MyApp.Services', 'MyApp.Legacy', 'MyApp.Models']),
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('still resolves a legitimate in-repo using via direct-match when evidence is present (Codex F2 guard)', () => {
|
||||
// Gating the direct-match must NOT over-block a legitimate aligned import:
|
||||
// `using MyApp.Services;` aligns (exact declared) so the gate passes and the
|
||||
// namespace-dir direct-child match still resolves.
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Services',
|
||||
importedName: 'MyApp.Services',
|
||||
targetRaw: 'MyApp.Services',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'MyApp/Program.cs',
|
||||
['MyApp/Program.cs', 'MyApp/Services/UserService.cs'],
|
||||
new Set(['MyApp.Services']),
|
||||
),
|
||||
);
|
||||
expect(result).toBe('MyApp/Services/UserService.cs');
|
||||
});
|
||||
|
||||
it('still resolves in-repo namespace imports via progressive stripping', () => {
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Models',
|
||||
importedName: 'MyApp.Models',
|
||||
targetRaw: 'MyApp.Models',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/UserService.cs',
|
||||
['Services/UserService.cs', 'Models/User.cs'],
|
||||
new Set(['MyApp.Models', 'MyApp.Services']),
|
||||
),
|
||||
);
|
||||
expect(result).toBe('Models/User.cs');
|
||||
});
|
||||
|
||||
it('drives the csproj-first branch: resolves via the internal resolver when configs exist (#7)', () => {
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Models',
|
||||
importedName: 'MyApp.Models',
|
||||
targetRaw: 'MyApp.Models',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Models/User.cs'],
|
||||
new Set(['MyApp.Services', 'MyApp.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(result).toBe('Models/User.cs');
|
||||
});
|
||||
|
||||
it('mirrors legacy authority: csproj present + internal-resolver-empty returns null, no ungated direct match (#2)', () => {
|
||||
// `Foo/Bar.cs` is an exact whole-path match that the ungated
|
||||
// `resolveDirectMatch` would have returned. With csproj configs present
|
||||
// and `Foo.Bar` outside the declared namespaces, the legacy strategy
|
||||
// returns an empty result that STOPS the chain — the registry path must
|
||||
// now do the same (return null) instead of falling through.
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Bar',
|
||||
importedName: 'Foo.Bar',
|
||||
targetRaw: 'Foo.Bar',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Foo/Bar.cs'],
|
||||
new Set(['MyApp.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('requires the rootNamespaces anchor end-to-end: parent-of import resolves only when anchored (#7)', () => {
|
||||
// `using MyApp.Core;` is an ancestor of declared `MyApp.Core.Models`.
|
||||
// The gate opens ONLY when `MyApp.Core` sits at/above an in-repo root, so
|
||||
// `Core/Thing.cs` resolves with roots {MyApp.Core} but not without them.
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Core',
|
||||
importedName: 'MyApp.Core',
|
||||
targetRaw: 'MyApp.Core',
|
||||
};
|
||||
const anchored = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Core/Thing.cs'],
|
||||
new Set(['MyApp.Core.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp.Core']),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(anchored).toBe('Core/Thing.cs');
|
||||
|
||||
const unanchored = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Core/Thing.cs'],
|
||||
new Set(['MyApp.Core.Models']),
|
||||
),
|
||||
);
|
||||
expect(unanchored).toBe(null);
|
||||
});
|
||||
|
||||
it('a sibling import outside the declared namespaces does not resolve even with roots (#7)', () => {
|
||||
// `using MyApp.Other;` is neither a child nor an ancestor of the only
|
||||
// declared namespace `MyApp.Models`, so the gate stays closed and the
|
||||
// otherwise-matchable `Other/Thing.cs` is left unresolved.
|
||||
const parsed: ParsedImport = {
|
||||
kind: 'namespace',
|
||||
localName: 'Other',
|
||||
importedName: 'MyApp.Other',
|
||||
targetRaw: 'MyApp.Other',
|
||||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Other/Thing.cs'],
|
||||
new Set(['MyApp.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importAlignsWithDeclaredNamespaces — declared-namespace gate (#1881)', () => {
|
||||
it('matches an exactly-declared namespace', () => {
|
||||
expect(importAlignsWithDeclaredNamespaces('MyApp.Models', new Set(['MyApp.Models']))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('child-of: import nested under a declared ancestor namespace', () => {
|
||||
// `using MyApp.Models.Detail;` when the repo declares `MyApp.Models`.
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces('MyApp.Models.Detail', new Set(['MyApp.Models'])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('child-of allows a using-static type under a declared namespace (#1)', () => {
|
||||
// `using static MyApp.Utils.Logger;` — the parent namespace `MyApp.Utils`
|
||||
// is declared, so the type import aligns even though `MyApp.Utils.Logger`
|
||||
// itself is not a declared namespace.
|
||||
expect(importAlignsWithDeclaredNamespaces('MyApp.Utils.Logger', new Set(['MyApp.Utils']))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('child-of stays anchored: a declared BCL root does NOT qualify a BCL using (#1)', () => {
|
||||
// A repo that declares `namespace System;` (a shim) must not green-light
|
||||
// `using System.Threading.Tasks;` — the import's parent `System.Threading`
|
||||
// is NOT declared, so the only match would be a coincidental local
|
||||
// `Tasks.cs`. The old "any declared prefix" rule re-opened #1881 here.
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces(
|
||||
'System.Threading.Tasks',
|
||||
new Set(['System', 'MyApp.Models']),
|
||||
new Set(['System', 'MyApp']),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('parent-of: parent-namespace import resolves against a declared child', () => {
|
||||
// `using MyApp;` when the repo declares `MyApp.Models` — must still open
|
||||
// the gate (anchored on the in-repo root namespace `MyApp`).
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces('MyApp', new Set(['MyApp.Models']), new Set(['MyApp'])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('parent-of works without explicit roots via the top-level declared segment', () => {
|
||||
expect(importAlignsWithDeclaredNamespaces('MyApp', new Set(['MyApp.Models']))).toBe(true);
|
||||
});
|
||||
|
||||
it('parent-of for a multi-segment csproj root (using MyApp; with RootNamespace MyApp.Core)', () => {
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces(
|
||||
'MyApp',
|
||||
new Set(['MyApp.Core.Models']),
|
||||
new Set(['MyApp.Core', 'MyApp']),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('parent-of stays anchored: a BCL prefix does NOT qualify via a locally-declared sub-namespace (#5)', () => {
|
||||
// A file declaring `namespace System.Threading.Tasks.Extensions` must not
|
||||
// open the gate for `using System.Threading.Tasks;`.
|
||||
const declared = new Set(['System.Threading.Tasks.Extensions', 'MyApp.Models']);
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces(
|
||||
'System.Threading.Tasks',
|
||||
declared,
|
||||
new Set(['MyApp', 'System']),
|
||||
),
|
||||
).toBe(false);
|
||||
// Same conclusion without explicit roots (top-level segment fallback).
|
||||
expect(importAlignsWithDeclaredNamespaces('System.Threading.Tasks', declared)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unrelated BCL namespace', () => {
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces(
|
||||
'System.Linq',
|
||||
new Set(['MyApp.Services']),
|
||||
new Set(['MyApp']),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an empty or undefined declared set', () => {
|
||||
expect(importAlignsWithDeclaredNamespaces('MyApp', new Set())).toBe(false);
|
||||
expect(importAlignsWithDeclaredNamespaces('MyApp', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('csharpSuffixFallbackAllowed — fail-open safety valves (#1881)', () => {
|
||||
const declared = new Set(['MyApp.Models']);
|
||||
const roots = new Set(['MyApp']);
|
||||
|
||||
it('blocks a non-aligned import when evidence is present and complete', () => {
|
||||
// Baseline: with complete evidence, a BCL using that aligns with nothing
|
||||
// declared in-repo is blocked.
|
||||
expect(
|
||||
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
|
||||
declaredNamespaces: declared,
|
||||
rootNamespaces: roots,
|
||||
truncated: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails OPEN (allows) when no evidence was threaded (#7)', () => {
|
||||
// The exact same import the complete-evidence case blocks must be ALLOWED
|
||||
// when evidence is undefined — preserving the pre-gate permissive behavior
|
||||
// for callers that never ran the scan.
|
||||
expect(csharpSuffixFallbackAllowed('System.Threading.Tasks', undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a clearly-external BCL root BLOCKED even when the scan was truncated (#1881, Codex F1)', () => {
|
||||
// A single truncation must NOT silently re-enable BCL→local suffix matches
|
||||
// repo-wide: System.* stays gated through truncation when the repo does not
|
||||
// declare it. (This reverses the prior blanket-fail-open for external roots.)
|
||||
expect(
|
||||
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
|
||||
declaredNamespaces: declared,
|
||||
rootNamespaces: roots,
|
||||
truncated: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails OPEN for a genuinely local-looking import when the scan was truncated (#6)', () => {
|
||||
// Non-external roots still fail open under truncation so an incomplete
|
||||
// (capped/unreadable) scan does not silently drop a legitimate in-repo edge.
|
||||
expect(
|
||||
csharpSuffixFallbackAllowed('MyApp.Internal.Widget', {
|
||||
declaredNamespaces: declared,
|
||||
rootNamespaces: roots,
|
||||
truncated: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an external root fail OPEN through truncation when the repo declares it (escape hatch)', () => {
|
||||
// If the repo actually declares the (normally-external) root, the alignment
|
||||
// escape hatch allows the import even under truncation.
|
||||
expect(
|
||||
csharpSuffixFallbackAllowed('System.Threading.Tasks', {
|
||||
declaredNamespaces: new Set(['System.Threading']),
|
||||
rootNamespaces: new Set(['System']),
|
||||
truncated: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('csharpScopeResolver.resolveImportTarget — config→ctx adapter wiring (#9)', () => {
|
||||
it('threads resolutionConfig.namespaces into the gate so a BCL using is blocked', () => {
|
||||
// Exercises the adapter (NOT resolveCsharpImportTarget directly): the
|
||||
// resolutionConfig that loadResolutionConfig returns must reach the gate as
|
||||
// ctx.namespaces. With a coincidental local `Tasks.cs` present and
|
||||
// `System.Threading.Tasks` outside the declared namespaces, the wired
|
||||
// evidence blocks the spurious edge.
|
||||
const result = csharpScopeResolver.resolveImportTarget(
|
||||
'System.Threading.Tasks',
|
||||
'Services/OrderService.cs',
|
||||
new Set(['Services/OrderService.cs', 'Tasks.cs']),
|
||||
{
|
||||
csharpConfigs: [],
|
||||
namespaces: {
|
||||
declaredNamespaces: new Set(['MyApp.Services', 'MyApp.Legacy']),
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('threads csharpConfigs so a csproj-mapped import resolves through the adapter', () => {
|
||||
// The other half of the wiring: csharpConfigs must reach ctx.csharpConfigs
|
||||
// so the csproj root-namespace mapping runs.
|
||||
const result = csharpScopeResolver.resolveImportTarget(
|
||||
'MyApp.Models',
|
||||
'Services/OrderService.cs',
|
||||
new Set(['Services/OrderService.cs', 'Models/User.cs']),
|
||||
{
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
namespaces: {
|
||||
declaredNamespaces: new Set(['MyApp.Models', 'MyApp.Services']),
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toBe('Models/User.cs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCsharpResolutionConfig — one-pass namespace scan (#1881)', () => {
|
||||
async function makeTempRepo(files: Record<string, string>): Promise<string> {
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'csharp-scan-'));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(root, rel);
|
||||
await fsp.mkdir(path.dirname(full), { recursive: true });
|
||||
await fsp.writeFile(full, content, 'utf-8');
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
it('collects file-scoped, block, and multiple-per-file namespaces; skips bin/obj; reads csproj root', async () => {
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj':
|
||||
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'Scoped.cs': 'namespace Alpha.Scoped;\npublic class A {}',
|
||||
'Block.cs': 'namespace Beta.Block\n{\n public class B {}\n}',
|
||||
'Multi.cs': 'namespace Gamma.One { }\nnamespace Gamma.Two { }',
|
||||
'bin/Generated.cs': 'namespace Should.Skip;',
|
||||
'obj/Temp.cs': 'namespace Should.AlsoSkip;',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(false);
|
||||
expect([...ns.declaredNamespaces!].sort()).toEqual([
|
||||
'Alpha.Scoped',
|
||||
'Beta.Block',
|
||||
'Gamma.One',
|
||||
'Gamma.Two',
|
||||
]);
|
||||
expect(ns.declaredNamespaces!.has('Should.Skip')).toBe(false);
|
||||
expect(ns.declaredNamespaces!.has('Should.AlsoSkip')).toBe(false);
|
||||
// csproj RootNamespace + top-level segment of each declared namespace.
|
||||
expect(ns.rootNamespaces!.has('MyApp')).toBe(true);
|
||||
expect([...ns.rootNamespaces!].sort()).toEqual(['Alpha', 'Beta', 'Gamma', 'MyApp']);
|
||||
expect(config.csharpConfigs).toHaveLength(1);
|
||||
expect(config.csharpConfigs[0]!.rootNamespace).toBe('MyApp');
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps truncated=false for a realistic-depth layout so the gate stays engaged (#1)', async () => {
|
||||
// A repo nested ~8 levels deep is well within the production cap
|
||||
// (CSHARP_SCAN_MAX_DEPTH=24). Were the cap as low as the old value (5),
|
||||
// this layout would trip `truncated` and disable the #1881 gate for the
|
||||
// whole repo. Proving truncated===false here pins the gate ON for repos
|
||||
// of normal depth.
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj':
|
||||
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'a/b/c/d/e/f/g/h/Deep.cs': 'namespace MyApp.Deep.Feature;',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(false);
|
||||
expect(ns.declaredNamespaces!.has('MyApp.Deep.Feature')).toBe(true);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('sets the truncation flag when the depth cap prunes a subtree (#11)', async () => {
|
||||
// repoRoot is depth 0; the chain below nests one level past the depth cap
|
||||
// (CSHARP_SCAN_MAX_DEPTH=24) so the deepest dir is pruned, its namespace
|
||||
// is missed, and the flag trips. Built relative to the real cap — do NOT
|
||||
// lower the production cap for the test.
|
||||
const deepChain = Array.from({ length: 25 }, (_, i) => `d${i}`).join('/');
|
||||
const root = await makeTempRepo({
|
||||
'Shallow.cs': 'namespace Shallow.Ns;',
|
||||
[`${deepChain}/Deep.cs`]: 'namespace Deep.Ns;',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(true);
|
||||
expect(ns.declaredNamespaces!.has('Shallow.Ns')).toBe(true);
|
||||
expect(ns.declaredNamespaces!.has('Deep.Ns')).toBe(false);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('streams a large .cs file end-to-end, collecting namespaces past the old size cap (#1881)', async () => {
|
||||
// The namespace scan streams each file, so a `.cs` far larger than the old
|
||||
// per-file size cap is read end-to-end in constant memory instead of being
|
||||
// skipped. A namespace at the START and one at the very END (well past the
|
||||
// old cap boundary) must BOTH be collected, and `truncated` must stay false
|
||||
// — a big generated file no longer disables the #1881 gate repo-wide.
|
||||
const cap = getMaxFileSizeBytes();
|
||||
const padLine = '// pad pad pad pad pad pad\n';
|
||||
const padding = padLine.repeat(Math.ceil((cap * 3) / padLine.length));
|
||||
const huge = `namespace Generated.Head;\n${padding}namespace Generated.Tail { }\n`;
|
||||
const root = await makeTempRepo({
|
||||
'Hand.cs': 'namespace Hand.Written;',
|
||||
'Generated.cs': huge,
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(false);
|
||||
expect(ns.declaredNamespaces!.has('Hand.Written')).toBe(true);
|
||||
expect(ns.declaredNamespaces!.has('Generated.Head')).toBe(true);
|
||||
expect(ns.declaredNamespaces!.has('Generated.Tail')).toBe(true);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('collects a Unicode namespace through the streamed scan, not truncated (Codex F3)', async () => {
|
||||
// The scanner is now Unicode-aware, so a non-ASCII namespace is captured
|
||||
// end-to-end instead of being dropped (which would over-block its imports).
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj':
|
||||
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'Models/Café.cs': 'namespace Café.App;\npublic class Modèle {}',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(false);
|
||||
expect(ns.declaredNamespaces!.has('Café.App')).toBe(true);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('marks the scan truncated when a file has an uncaptured namespace form, failing the gate OPEN (Codex F3)', async () => {
|
||||
// A namespace split across lines is not captured by the line scanner; the
|
||||
// scan must flag truncated so the dropped namespace fails the #1881 gate
|
||||
// OPEN rather than over-block an import declared in that file.
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj':
|
||||
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'Weird.cs': 'namespace\n MyApp.Weird;\npublic class W {}',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(true);
|
||||
// A local-looking import under the dropped namespace fails open (and U1's
|
||||
// external-root denylist still keeps BCL roots blocked under truncation).
|
||||
expect(csharpSuffixFallbackAllowed('MyApp.Weird.Thing', ns)).toBe(true);
|
||||
expect(csharpSuffixFallbackAllowed('System.Threading.Tasks', ns)).toBe(false);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('recovers <RootNamespace> past the old read cap via streaming (Codex F4)', async () => {
|
||||
// A big leading <ItemGroup> pushes <RootNamespace> past the old 512KB read
|
||||
// cap; the streamed scan reads on until it finds the tag, so the correct
|
||||
// root is recovered (pre-fix the capped read synthesized the filename 'App').
|
||||
const cap = getMaxFileSizeBytes();
|
||||
const itemLine = ' <Compile Include="src/Generated/F.cs" />\n';
|
||||
const bigItemGroup =
|
||||
' <ItemGroup>\n' +
|
||||
itemLine.repeat(Math.ceil((cap * 2) / itemLine.length)) +
|
||||
' </ItemGroup>\n';
|
||||
const csproj =
|
||||
'<Project Sdk="Microsoft.NET.Sdk">\n' +
|
||||
bigItemGroup +
|
||||
' <PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup>\n' +
|
||||
'</Project>\n';
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj': csproj,
|
||||
'Models/User.cs': 'namespace MyApp.Models;\npublic class User {}',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
expect(config.csharpConfigs).toHaveLength(1);
|
||||
expect(config.csharpConfigs[0]!.rootNamespace).toBe('MyApp');
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the filename root only when <RootNamespace> is genuinely absent (Codex F4 control)', async () => {
|
||||
// A genuine read-to-EOF absence still synthesizes the filename root, so a
|
||||
// .csproj without RootNamespace is unchanged — the fix only avoids guessing
|
||||
// when the tag was unreachable.
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj':
|
||||
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>',
|
||||
'Models/User.cs': 'namespace App.Models;\npublic class User {}',
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
expect(config.csharpConfigs).toHaveLength(1);
|
||||
expect(config.csharpConfigs[0]!.rootNamespace).toBe('App');
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue