Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c978c9b3d4 | ||
|
|
a93ecee068 | ||
|
|
b9008024ab | ||
|
|
cfe4e49c41 | ||
|
|
1e2aaeabf9 | ||
|
|
fa8033859f | ||
|
|
2cb39bc09b | ||
|
|
05e67c683f | ||
|
|
66daf27910 | ||
|
|
e12b526e07 | ||
|
|
5f0841c8bc | ||
|
|
b090f2b4e1 | ||
|
|
f86150707c | ||
|
|
4b787be835 | ||
|
|
a62a7e56cb | ||
|
|
150a95bae4 | ||
|
|
ed33489e38 | ||
|
|
eaf0a3052a | ||
|
|
5d1695f66a |
@@ -660,6 +660,14 @@ UPSTREAM (what depends on this):
|
||||
|
||||
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`, `limit` (max symbols per depth, default 100), `offset` (pagination start per depth), `summaryOnly` (counts and risk only, omits symbol list)
|
||||
|
||||
**Disambiguation** — when several symbols share the target name, `impact` returns a ranked `ambiguous` candidate list instead of guessing. Narrow it with `target_uid` (exact, zero-ambiguity), `file_path`, or `kind` (`Function`, `Class`, `Method`, …). From the CLI these are `--uid`, `--file`, and `--kind`, matching `gitnexus context`:
|
||||
|
||||
```bash
|
||||
gitnexus impact get_embeddings # → ambiguous: lists ranked candidates
|
||||
gitnexus impact get_embeddings --file src/embed.py # → resolves to the one in that file
|
||||
gitnexus impact get_embeddings --uid "Function:src/embed.py:get_embeddings" # exact
|
||||
```
|
||||
|
||||
### Process-Grouped Search
|
||||
|
||||
```
|
||||
|
||||
@@ -170,6 +170,13 @@ gitnexus clean --all --force # Delete all indexes
|
||||
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
|
||||
# Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed
|
||||
gitnexus query "<concept>" # Process-grouped hybrid search
|
||||
gitnexus context <symbol> [--uid <uid> | --file <path>] # 360° symbol view; flags disambiguate a shared name
|
||||
gitnexus impact <symbol> [--uid <uid> | --file <path> | --kind <kind>] # Blast radius; flags disambiguate a shared name
|
||||
gitnexus detect-changes # Map the working-tree diff to affected symbols and execution flows
|
||||
gitnexus cypher "<query>" # Run a raw Cypher query against the knowledge graph
|
||||
|
||||
# Repository groups (multi-repo / monorepo service tracking)
|
||||
gitnexus group create <name> # Create a repository group
|
||||
gitnexus group add <group> <groupPath> <registryName> # Add a repo to a group. <groupPath> is a hierarchy path (e.g. hr/hiring/backend); <registryName> is the repo's name from the registry (see `gitnexus list`)
|
||||
|
||||
@@ -101,6 +101,9 @@ const OPTION_DESCRIPTION_KEYS = {
|
||||
'context|--content': 'help.option.content',
|
||||
'impact|-d, --direction <dir>': 'help.option.impact.direction',
|
||||
'impact|-r, --repo <name>': 'help.option.repo.target',
|
||||
'impact|-u, --uid <uid>': 'help.option.context.uid',
|
||||
'impact|-f, --file <path>': 'help.option.context.file',
|
||||
'impact|--kind <kind>': 'help.option.impact.kind',
|
||||
'impact|--depth <n>': 'help.option.impact.depth',
|
||||
'impact|--include-tests': 'help.option.impact.includeTests',
|
||||
'impact|--limit <n>': 'help.option.impact.limit',
|
||||
|
||||
@@ -43,8 +43,11 @@ export const en = {
|
||||
'tool.noIndexed': 'GitNexus: No indexed repositories found. Run: gitnexus analyze',
|
||||
'tool.usage.query': 'Usage: gitnexus query <search_query>',
|
||||
'tool.usage.context': 'Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]',
|
||||
'tool.usage.impact': 'Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]',
|
||||
'tool.usage.impact':
|
||||
'Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]',
|
||||
'tool.usage.cypher': 'Usage: gitnexus cypher <cypher_query>',
|
||||
'tool.warn.unknownKind':
|
||||
"--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.",
|
||||
'tool.detectChanges.noChanges': 'No changes detected.',
|
||||
'tool.detectChanges.changesSummary': 'Changes: {{files}} files, {{symbols}} symbols',
|
||||
'tool.detectChanges.affectedProcesses': 'Affected processes: {{count}}',
|
||||
@@ -213,6 +216,8 @@ export const en = {
|
||||
'help.option.repo.target': 'Target repository',
|
||||
'help.option.context.uid': 'Direct symbol UID (zero-ambiguity lookup)',
|
||||
'help.option.context.file': 'File path to disambiguate common names',
|
||||
'help.option.impact.kind':
|
||||
'Kind filter to disambiguate common names (e.g. Function, Class, Method)',
|
||||
'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)',
|
||||
'help.option.impact.depth': 'Max relationship depth (default: 3)',
|
||||
'help.option.impact.includeTests': 'Include test files in results',
|
||||
|
||||
@@ -47,8 +47,11 @@ export const zhCN = {
|
||||
'tool.noIndexed': 'GitNexus:未找到已索引仓库。请运行:gitnexus analyze',
|
||||
'tool.usage.query': '用法:gitnexus query <搜索词>',
|
||||
'tool.usage.context': '用法:gitnexus context <符号名> [--uid <uid>] [--file <路径>]',
|
||||
'tool.usage.impact': '用法:gitnexus impact <符号名> [--direction upstream|downstream]',
|
||||
'tool.usage.impact':
|
||||
'用法:gitnexus impact <符号名> [--uid <uid>] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]',
|
||||
'tool.usage.cypher': '用法:gitnexus cypher <Cypher 查询>',
|
||||
'tool.warn.unknownKind':
|
||||
"--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。",
|
||||
'tool.detectChanges.noChanges': '未检测到变更。',
|
||||
'tool.detectChanges.changesSummary': '变更:{{files}} 个文件,{{symbols}} 个符号',
|
||||
'tool.detectChanges.affectedProcesses': '受影响流程:{{count}}',
|
||||
@@ -199,6 +202,7 @@ export const zhCN = {
|
||||
'help.option.repo.target': '目标仓库',
|
||||
'help.option.context.uid': '直接符号 UID(零歧义查找)',
|
||||
'help.option.context.file': '用于消除常见名称歧义的文件路径',
|
||||
'help.option.impact.kind': '用于消除常见名称歧义的类型过滤(如 Function、Class、Method)',
|
||||
'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)',
|
||||
'help.option.impact.depth': '最大关系遍历深度(默认:3)',
|
||||
'help.option.impact.includeTests': '在结果中包含测试文件',
|
||||
|
||||
@@ -219,10 +219,16 @@ program
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
|
||||
program
|
||||
.command('impact <target>')
|
||||
.command('impact [target]')
|
||||
.description('Blast radius analysis: what breaks if you change a symbol')
|
||||
.option('-d, --direction <dir>', 'upstream (dependants) or downstream (dependencies)', 'upstream')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option(
|
||||
'--kind <kind>',
|
||||
'Kind filter to disambiguate common names (e.g. Function, Class, Method)',
|
||||
)
|
||||
.option('--depth <n>', 'Max relationship depth (default: 3)')
|
||||
.option('--include-tests', 'Include test files in results')
|
||||
.option('--limit <n>', 'Max symbols per depth level (default: 100)')
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
*/
|
||||
|
||||
import { writeSync } from 'node:fs';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { cliErrorKey } from './cli-message.js';
|
||||
import { LocalBackend, VALID_NODE_LABELS } from '../mcp/local/local-backend.js';
|
||||
import { cliErrorKey, cliWarnKey } from './cli-message.js';
|
||||
import { formatDetectChangesResult } from './detect-changes-format.js';
|
||||
|
||||
let _backend: LocalBackend | null = null;
|
||||
@@ -94,6 +94,11 @@ export async function contextCommand(
|
||||
content?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
// Reject a `--`-prefixed uid swallowed from a following flag (see impactCommand).
|
||||
if (options?.uid?.startsWith('--')) {
|
||||
cliErrorKey('tool.usage.context');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!name?.trim() && !options?.uid) {
|
||||
cliErrorKey('tool.usage.context');
|
||||
process.exit(1);
|
||||
@@ -111,10 +116,13 @@ export async function contextCommand(
|
||||
}
|
||||
|
||||
export async function impactCommand(
|
||||
target: string,
|
||||
target?: string,
|
||||
options?: {
|
||||
direction?: string;
|
||||
repo?: string;
|
||||
uid?: string;
|
||||
file?: string;
|
||||
kind?: string;
|
||||
depth?: string;
|
||||
includeTests?: boolean;
|
||||
limit?: string;
|
||||
@@ -122,10 +130,25 @@ export async function impactCommand(
|
||||
summaryOnly?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!target?.trim()) {
|
||||
// A `--`-prefixed uid means Commander swallowed a following flag as the uid
|
||||
// value (e.g. `impact --uid --file x` → uid === '--file'). Reject it rather
|
||||
// than forwarding a garbage uid that would silently resolve to not-found.
|
||||
if (options?.uid?.startsWith('--')) {
|
||||
cliErrorKey('tool.usage.impact');
|
||||
process.exit(1);
|
||||
}
|
||||
// Target is an optional positional: a uid alone is enough to resolve (parity
|
||||
// with `context [name]`). Only error when neither a target nor a uid is given.
|
||||
if (!target?.trim() && !options?.uid) {
|
||||
cliErrorKey('tool.usage.impact');
|
||||
process.exit(1);
|
||||
}
|
||||
// Soft-validate --kind: an unknown kind is a no-op hint (the backend scores
|
||||
// it but it matches nothing), so warn and proceed rather than rejecting —
|
||||
// parity with the lenient MCP surface and forward-compatible with new labels.
|
||||
if (options?.kind && !VALID_NODE_LABELS.has(options.kind)) {
|
||||
cliWarnKey('tool.warn.unknownKind', { kind: options.kind });
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = await getBackend();
|
||||
@@ -134,7 +157,10 @@ export async function impactCommand(
|
||||
const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined;
|
||||
const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined;
|
||||
const result = await backend.callTool('impact', {
|
||||
target,
|
||||
target: target || undefined,
|
||||
target_uid: options?.uid,
|
||||
file_path: options?.file,
|
||||
kind: options?.kind,
|
||||
direction: options?.direction || 'upstream',
|
||||
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
|
||||
includeTests: options?.includeTests ?? false,
|
||||
|
||||
@@ -721,12 +721,19 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Native OpenFeign `@RequestLine("METHOD /path")`. Method-level only and
|
||||
// always declared on an interface (Feign builds a proxy from the interface).
|
||||
// We do NOT require an enclosing `@FeignClient`: `@RequestLine` is a core
|
||||
// `feign.*` annotation used with `Feign.builder()`, whereas `@FeignClient`
|
||||
// is the Spring Cloud variant that uses Spring MVC annotations instead — the
|
||||
// two are effectively mutually exclusive, so requiring `@FeignClient` here
|
||||
// would miss the annotation's primary use. The `RequestLine` name is itself
|
||||
// a strong, framework-specific signal, so a structural interface check is
|
||||
// enough to keep false positives away. A `@FeignClient(path=...)` prefix is
|
||||
// still applied when present (rare, but harmless).
|
||||
for (const requestLine of requestLines) {
|
||||
const enclosingInterface = findEnclosingInterface(requestLine.methodNode);
|
||||
if (!enclosingInterface || !hasAnnotation(enclosingInterface, 'FeignClient')) continue;
|
||||
if (!enclosingInterface) continue;
|
||||
const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? '';
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getGoParser, getGoScopeQuery } from './query.js';
|
||||
import { recordGoCacheHit, recordGoCacheMiss } from './cache-stats.js';
|
||||
import { computeGoCallArity, computeGoDeclarationArity } from './arity-metadata.js';
|
||||
@@ -34,18 +29,29 @@ export function emitGoScopeCaptures(
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
// Parallel tag -> captured SyntaxNode map. The tree-sitter query already
|
||||
// hands us the matched node as `c.node`; keeping it here lets us derive the
|
||||
// anchor/relative node by walking LOCALLY (parent chain / own subtree)
|
||||
// instead of re-walking from tree.rootNode (the O(matches x rootChildren)
|
||||
// hotpath that made #1848's 250-struct DAO file take ~10s). The captured
|
||||
// node either IS the node the old findNodeAtRange re-derived, or is a close
|
||||
// relative reachable by a bounded local walk.
|
||||
const nodeMap: Record<string, SyntaxNode> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
if (tag.startsWith('@_')) continue; // skip anonymous captures
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
nodeMap[tag] = c.node;
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const anchor = grouped['@import.statement']!;
|
||||
const importNode =
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'import_declaration') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'import_spec');
|
||||
// The captured node is the `import_spec`; the original code preferred its
|
||||
// enclosing `import_declaration` ONLY when that ancestor shares the exact
|
||||
// same range (which never happens — the declaration always includes the
|
||||
// `import` keyword prefix — so it falls back to the import_spec itself).
|
||||
// Replicate that exactly via a local ancestor walk, never from root.
|
||||
const importNode = resolveImportNode(nodeMap['@import.statement']!);
|
||||
if (importNode !== null) {
|
||||
out.push(...splitGoImportStatement(importNode));
|
||||
continue;
|
||||
@@ -53,23 +59,33 @@ export function emitGoScopeCaptures(
|
||||
}
|
||||
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
const scopeCap = grouped['@scope.function']!;
|
||||
// @scope.function captures function_declaration | method_declaration |
|
||||
// func_literal. The original looked for a function_declaration or
|
||||
// method_declaration at the captured range; the captured node IS that
|
||||
// node for the first two, and a func_literal never coincides in range
|
||||
// with either, so the lookup yields null for func_literal.
|
||||
const scopeNode = nodeMap['@scope.function']!;
|
||||
const fnNode =
|
||||
findNodeAtRange(tree.rootNode, scopeCap.range, 'function_declaration') ??
|
||||
findNodeAtRange(tree.rootNode, scopeCap.range, 'method_declaration');
|
||||
scopeNode.type === 'function_declaration' || scopeNode.type === 'method_declaration'
|
||||
? scopeNode
|
||||
: null;
|
||||
if (fnNode !== null) {
|
||||
const receiver = synthesizeGoReceiverBinding(fnNode);
|
||||
if (receiver !== null) out.push(receiver);
|
||||
}
|
||||
}
|
||||
|
||||
if (isRawMultiAssignTypeBinding(tree.rootNode, grouped)) continue;
|
||||
if (isRawMultiAssignTypeBinding(nodeMap)) continue;
|
||||
|
||||
const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method'];
|
||||
if (declAnchor !== undefined) {
|
||||
const declAnchorNode = nodeMap['@declaration.function'] ?? nodeMap['@declaration.method'];
|
||||
if (declAnchorNode !== undefined) {
|
||||
// @declaration.function / @declaration.method are captured directly on
|
||||
// the function_declaration / method_declaration node.
|
||||
const fnNode =
|
||||
findNodeAtRange(tree.rootNode, declAnchor.range, 'function_declaration') ??
|
||||
findNodeAtRange(tree.rootNode, declAnchor.range, 'method_declaration');
|
||||
declAnchorNode.type === 'function_declaration' ||
|
||||
declAnchorNode.type === 'method_declaration'
|
||||
? declAnchorNode
|
||||
: null;
|
||||
if (fnNode !== null) {
|
||||
const arity = computeGoDeclarationArity(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
@@ -98,15 +114,15 @@ export function emitGoScopeCaptures(
|
||||
continue;
|
||||
}
|
||||
|
||||
const callAnchor =
|
||||
grouped['@reference.call.free'] ??
|
||||
grouped['@reference.call.member'] ??
|
||||
grouped['@reference.call.constructor'];
|
||||
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const callNode =
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'composite_literal');
|
||||
if (callNode !== null) {
|
||||
// @reference.call.free / .member are captured on the call_expression;
|
||||
// @reference.call.constructor on the composite_literal. The captured node
|
||||
// IS the node the old findNodeAtRange re-derived for each, so use it.
|
||||
const callNode =
|
||||
nodeMap['@reference.call.free'] ??
|
||||
nodeMap['@reference.call.member'] ??
|
||||
nodeMap['@reference.call.constructor'];
|
||||
if (callNode !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
if (callNode.type === 'call_expression' || callNode.type === 'composite_literal') {
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
@@ -146,18 +162,56 @@ export function emitGoScopeCaptures(
|
||||
return out;
|
||||
}
|
||||
|
||||
function isRawMultiAssignTypeBinding(
|
||||
rootNode: SyntaxNode,
|
||||
grouped: Record<string, Capture>,
|
||||
): boolean {
|
||||
/**
|
||||
* Resolve the node passed to `splitGoImportStatement` for an @import.statement
|
||||
* match. The capture is on the `import_spec`; the original preferred an
|
||||
* `import_declaration` at the SAME range, else the import_spec. An
|
||||
* import_declaration always includes the `import` keyword and so never shares
|
||||
* the spec's exact range — the only candidate is an ancestor, and it can only
|
||||
* match when ranges coincide. Walk the parent chain (bounded, local) for an
|
||||
* import_declaration whose range equals the spec's; otherwise return the spec.
|
||||
*/
|
||||
function resolveImportNode(importSpec: SyntaxNode): SyntaxNode {
|
||||
let current: SyntaxNode | null = importSpec.parent;
|
||||
while (current !== null) {
|
||||
if (current.type === 'import_declaration') {
|
||||
if (nodeRangeEquals(current, importSpec)) return current;
|
||||
break;
|
||||
}
|
||||
// import_spec is nested at most under import_declaration ->
|
||||
// import_spec_list -> import_spec; stop once we leave the import subtree.
|
||||
if (current.type !== 'import_spec_list') break;
|
||||
current = current.parent;
|
||||
}
|
||||
return importSpec;
|
||||
}
|
||||
|
||||
/** True iff two nodes occupy the exact same source range. */
|
||||
function nodeRangeEquals(a: SyntaxNode, b: SyntaxNode): boolean {
|
||||
return (
|
||||
a.startPosition.row === b.startPosition.row &&
|
||||
a.startPosition.column === b.startPosition.column &&
|
||||
a.endPosition.row === b.endPosition.row &&
|
||||
a.endPosition.column === b.endPosition.column
|
||||
);
|
||||
}
|
||||
|
||||
function isRawMultiAssignTypeBinding(nodeMap: Record<string, SyntaxNode>): boolean {
|
||||
const anchor =
|
||||
grouped['@type-binding.constructor'] ??
|
||||
grouped['@type-binding.call-return'] ??
|
||||
grouped['@type-binding.assertion'];
|
||||
nodeMap['@type-binding.constructor'] ??
|
||||
nodeMap['@type-binding.call-return'] ??
|
||||
nodeMap['@type-binding.assertion'];
|
||||
if (anchor === undefined) return false;
|
||||
|
||||
const node = findNodeAtRange(rootNode, anchor.range, 'short_var_declaration');
|
||||
if (node === null) return false;
|
||||
// These tags are captured directly ON the short_var_declaration, so the
|
||||
// captured node IS what the original findNodeAtRange(root, range,
|
||||
// 'short_var_declaration') re-derived. The var_declaration (var-form)
|
||||
// variants — @type-binding.assertion (`var x = e.(T)`) and
|
||||
// @type-binding.call-return (`var x = Func()`) — anchor on a var_declaration
|
||||
// instead; the old range+type lookup found no short_var_declaration at that
|
||||
// range and returned null -> false, which this type guard reproduces exactly.
|
||||
if (anchor.type !== 'short_var_declaration') return false;
|
||||
const node = anchor;
|
||||
const lhs = node.childForFieldName('left');
|
||||
const rhs = node.childForFieldName('right');
|
||||
if (lhs === null) return false;
|
||||
|
||||
@@ -2920,8 +2920,14 @@ export class LocalBackend {
|
||||
typeof opts.offset === 'number' && Number.isFinite(opts.offset) ? opts.offset : 0;
|
||||
const paginationOffset = Math.max(0, Math.trunc(rawOffset));
|
||||
const summaryOnly = opts.summaryOnly ?? false;
|
||||
const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', ');
|
||||
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
|
||||
// Bind the BFS frontier query's filters as parameters (#1907 review F5):
|
||||
// node ids and relation types as bound lists, the confidence floor as a
|
||||
// bound number — no string interpolation reaches the query text. Preserve
|
||||
// the original "no confidence clause when minConfidence <= 0" behavior: an
|
||||
// unconditional `>= 0` would wrongly exclude NULL-confidence edges that the
|
||||
// unfiltered query includes.
|
||||
const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0;
|
||||
const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : '';
|
||||
|
||||
const symId = sym.id || sym[0];
|
||||
|
||||
@@ -3009,15 +3015,19 @@ export class LocalBackend {
|
||||
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
||||
const nextFrontier: string[] = [];
|
||||
|
||||
// Batch frontier nodes into a single Cypher query per depth level
|
||||
const idList = frontier.map((id) => `'${id.replace(/'/g, "''")}'`).join(', ');
|
||||
// Batch frontier nodes into a single Cypher query per depth level.
|
||||
// ids/types/confidence are bound parameters (see above) — no interpolation.
|
||||
const query =
|
||||
direction === 'upstream'
|
||||
? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
|
||||
: `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN [${idList}] AND r.type IN [${relTypeFilter}]${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
|
||||
? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
|
||||
: `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
|
||||
|
||||
try {
|
||||
const related = await executeQuery(repo.id, query);
|
||||
const related = await executeParameterized(repo.id, query, {
|
||||
frontierIds: frontier,
|
||||
relTypes: relationTypes,
|
||||
...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}),
|
||||
});
|
||||
|
||||
for (const rel of related) {
|
||||
const relId = rel.id || rel[1];
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"go-aliased-package-import/internal/util/log.go": {
|
||||
"captureGroups": 4,
|
||||
"digest": "77be0bd9a9df464f42a6cefd0c16064d4ef97e79f5f61faa8df6069989cec9f6"
|
||||
},
|
||||
"go-aliased-package-import/main.go": {
|
||||
"captureGroups": 7,
|
||||
"digest": "3eb2e6d441dadede554b271b7a87b7b9ca557ab418bfb9e8524e6ace4c8fc547"
|
||||
},
|
||||
"go-ambiguous/internal/models/handler.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "619c516a5791095bc6380de62fa861364b3f9480f2ec87d4499c2c998f228713"
|
||||
},
|
||||
"go-ambiguous/internal/other/handler.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "08a61721581c4f17741ef0c4ee1c8945235ee7f2aca7d88d2fe122071cd62e6f"
|
||||
},
|
||||
"go-ambiguous/internal/services/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "802b81a07c64c01f2381cf33d41cdb1a58f535c0007b5ae151c38cda87f28321"
|
||||
},
|
||||
"go-assignment-chain/cmd/main.go": {
|
||||
"captureGroups": 50,
|
||||
"digest": "47ba5fd2ea96ee202b3a3de5c0db75ea18d0889064ed2138d62c67594f7d22e9"
|
||||
},
|
||||
"go-assignment-chain/models/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801"
|
||||
},
|
||||
"go-assignment-chain/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a"
|
||||
},
|
||||
"go-call-result-binding/cmd/main.go": {
|
||||
"captureGroups": 16,
|
||||
"digest": "83d611dcee826ec848a0b3fd5a18a98103896d17c353d8d63cd15551039818e9"
|
||||
},
|
||||
"go-call-result-binding/models/user.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175"
|
||||
},
|
||||
"go-calls/cmd/main.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "deaea55087c2652aa2d39fefa30048d5393cec40f7af1ab13921331b035030d6"
|
||||
},
|
||||
"go-calls/internal/onearg/log.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "a101eafe9f08396bb176cf3cadd3960d752802048e47c0ac9e9ace07244a46fb"
|
||||
},
|
||||
"go-calls/internal/zeroarg/log.go": {
|
||||
"captureGroups": 5,
|
||||
"digest": "7b322767a38298de8c6ce99fa6b1d69dda8bbb79704053644bfaa7d2e4473fdb"
|
||||
},
|
||||
"go-chain-call/cmd/main.go": {
|
||||
"captureGroups": 20,
|
||||
"digest": "5a8d7de8ae87887902d16f28cb70d31014c96ab5d2eb63cbd6c8be27650fa9a1"
|
||||
},
|
||||
"go-chain-call/models/repo.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029"
|
||||
},
|
||||
"go-chain-call/models/user.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175"
|
||||
},
|
||||
"go-child-extends-parent/models/child.go": {
|
||||
"captureGroups": 3,
|
||||
"digest": "6fd9fe7b82066f82a93bf5e5024ddf89382091ec04e55648845c8295f13bd412"
|
||||
},
|
||||
"go-child-extends-parent/models/parent.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "454a724f571a5aede89d7657f76ed8e3c9b19bfc18d524141d1b646005f98e49"
|
||||
},
|
||||
"go-child-extends-parent/services/app.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "05f5df0369c90bc6e0da5a8a76e0661be36cddbe913ab2f83da2ee3733a46ec2"
|
||||
},
|
||||
"go-cmd-helper/cmd/server/internal/config/config.go": {
|
||||
"captureGroups": 5,
|
||||
"digest": "b10874198d380b0a186fb1e1ee8cedac644eb3b6b624398110a660183e59a0b5"
|
||||
},
|
||||
"go-cmd-helper/cmd/server/main.go": {
|
||||
"captureGroups": 7,
|
||||
"digest": "a1f9453bd71926d60e3f148f43b9af813cbd1cccc11b323896a55bdb443f8931"
|
||||
},
|
||||
"go-constructor-type-inference/cmd/main.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "4c496bf5ebaebae8c7480b1826b3285752a79ce50562756ded3b7fe0c6d6b325"
|
||||
},
|
||||
"go-constructor-type-inference/models/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801"
|
||||
},
|
||||
"go-constructor-type-inference/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a"
|
||||
},
|
||||
"go-deep-field-chain/cmd/main.go": {
|
||||
"captureGroups": 13,
|
||||
"digest": "ca9cc7ae0f75928b1ea338f42e58cf02502e0c93ce4ea8867258c90543f79d16"
|
||||
},
|
||||
"go-deep-field-chain/models/models.go": {
|
||||
"captureGroups": 33,
|
||||
"digest": "0ad1df946f58e446a8dbd8ef13041b6e0177f53293946b955acf3c46684e4295"
|
||||
},
|
||||
"go-field-types/cmd/main.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "3bc98cf5640568cdff3202f2594fb39d563ee595560cb28d1adb31f264670ceb"
|
||||
},
|
||||
"go-field-types/models/models.go": {
|
||||
"captureGroups": 22,
|
||||
"digest": "fbdbf74d927c0ed07f4820c190fc6f2039b74ddead8abb45fc238812dfa4d4ef"
|
||||
},
|
||||
"go-for-call-expr/cmd/main.go": {
|
||||
"captureGroups": 27,
|
||||
"digest": "95217f85260d85baeb57638fff470c0a358be92cc49a5f918b084d809f47ba2a"
|
||||
},
|
||||
"go-for-call-expr/models/repo.go": {
|
||||
"captureGroups": 14,
|
||||
"digest": "312e59c1402cb83a4ce51c27866fde6c597f7121098b57ff1d3bd4dc6363834a"
|
||||
},
|
||||
"go-for-call-expr/models/user.go": {
|
||||
"captureGroups": 14,
|
||||
"digest": "c442a26c4051c2b7426850a381137507a147c21d631d6535d71ef1035b1506fb"
|
||||
},
|
||||
"go-inc-dec-write-access/main.go": {
|
||||
"captureGroups": 21,
|
||||
"digest": "0414398239624e44b1589f6a68f2c19636bf4b3ce74990cee1bd7cbeeb0591e6"
|
||||
},
|
||||
"go-local-shadow/cmd/main.go": {
|
||||
"captureGroups": 12,
|
||||
"digest": "1cda9982d9b4e4878208894523f0a9c7a806c6f08a5582d4771205716a1b2733"
|
||||
},
|
||||
"go-local-shadow/internal/utils/utils.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "51e941fa4c7765efc6e472d15a9d7ea31f59b67a6b606be868f4af47e5f54cb1"
|
||||
},
|
||||
"go-make-builtin/main.go": {
|
||||
"captureGroups": 18,
|
||||
"digest": "7c56328d8416338ae0075ae7dd9669b16f2035aa353fac7ee1e46109a58e80a6"
|
||||
},
|
||||
"go-make-builtin/models.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "4d5628f66471f1ad1180a47b797195506d190ddc84f61f6c57f2e22afd754c1e"
|
||||
},
|
||||
"go-map-range/main.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "7f3580a0e7858e6eb3176e0b5e1bec4867a2d5d07f2b216a04a3de79ff96170b"
|
||||
},
|
||||
"go-map-range/models/repo.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "6cbc4422fb287007735b4c58b5e9c84bbd6b8f6082e3b4d5fe48c306e6acc75b"
|
||||
},
|
||||
"go-map-range/models/user.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "7e4dbc05ad1de859cd3103c27cddb60566d95a8a8354c752e8c75f84d7ca055c"
|
||||
},
|
||||
"go-member-calls/cmd/main.go": {
|
||||
"captureGroups": 11,
|
||||
"digest": "e46f6d1dff39943f8f89c05e0d28f61f8471cdc729c91f01ca693a38a244b8ba"
|
||||
},
|
||||
"go-member-calls/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a"
|
||||
},
|
||||
"go-method-chain-binding/cmd/main.go": {
|
||||
"captureGroups": 21,
|
||||
"digest": "0fff0df5dd77e13e0b9e62dd7f38efc038d33ffd5d891f684289eccbc46ff583"
|
||||
},
|
||||
"go-method-chain-binding/models/user.go": {
|
||||
"captureGroups": 24,
|
||||
"digest": "504ea598176dd8e01d759cc54e012735c746f14ec3a660af2c362fc356326f65"
|
||||
},
|
||||
"go-method-enrichment/animal.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "ac0933f59d4a88a25629d02f308c66847fd34ceba5831660fc8bff07109b7708"
|
||||
},
|
||||
"go-method-enrichment/app.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "ac0bdc2e6daf7d4e28fd255fe2edd143e8fab7ff316f4e32e2ca04a3b33f71c2"
|
||||
},
|
||||
"go-mixed-chain/cmd/main.go": {
|
||||
"captureGroups": 20,
|
||||
"digest": "3662b803da4f4fc1ac0552a45d3d262bd0db57f97b056614caacea8ad856250e"
|
||||
},
|
||||
"go-mixed-chain/models/models.go": {
|
||||
"captureGroups": 41,
|
||||
"digest": "dc2cedaefcd73faf13a9f704a4be8f0bd4140c608cd86480e1f4400fec49dbd1"
|
||||
},
|
||||
"go-multi-assign/app.go": {
|
||||
"captureGroups": 16,
|
||||
"digest": "66958a3e86caa54aedd795227dac274cfb3f4b39ab98964e8a7bcbdfc8a08ca6"
|
||||
},
|
||||
"go-multi-assign/models.go": {
|
||||
"captureGroups": 19,
|
||||
"digest": "a4d43cf2cd2f7bdbc750a7ee1f5e9c7611e5d1e25f1a46386d0eeb9f73bb7846"
|
||||
},
|
||||
"go-multi-return-inference/cmd/main.go": {
|
||||
"captureGroups": 36,
|
||||
"digest": "6229b69cfd15bf1465d770d97486522faac5a91cf8828c424c8c8c989b310224"
|
||||
},
|
||||
"go-multi-return-inference/models/repo.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029"
|
||||
},
|
||||
"go-multi-return-inference/models/user.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175"
|
||||
},
|
||||
"go-new-builtin/main.go": {
|
||||
"captureGroups": 12,
|
||||
"digest": "1177b99217a42a28b0d768d29e1df4198f0d5ca5f1c2b584d528f5f02354b38f"
|
||||
},
|
||||
"go-new-builtin/models.go": {
|
||||
"captureGroups": 17,
|
||||
"digest": "40c9f89942406caf2610f0d1954b66e905c39dd5d617d7359e572445d586c15d"
|
||||
},
|
||||
"go-nullable-receiver/cmd/main.go": {
|
||||
"captureGroups": 27,
|
||||
"digest": "7a93b339f8c68ed3882802da21343c1d6b5231ae5cd8dd13eb4c05e2e7716320"
|
||||
},
|
||||
"go-nullable-receiver/models/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801"
|
||||
},
|
||||
"go-nullable-receiver/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a"
|
||||
},
|
||||
"go-parent-resolution/models/base.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "2afaeb50d544a55fe437ef20e2c0de92152d2ba62f2693c329255787bb3d0a02"
|
||||
},
|
||||
"go-parent-resolution/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "c76ba16343dd94024fdaac10fa7640536966e530d675c0084434f42edb5b5f15"
|
||||
},
|
||||
"go-pkg/cmd/main.go": {
|
||||
"captureGroups": 14,
|
||||
"digest": "a7781d23802e876b9ca2951b7ebb317c5df5adf390ae9a57e8e2906af34f3255"
|
||||
},
|
||||
"go-pkg/internal/auth/service.go": {
|
||||
"captureGroups": 17,
|
||||
"digest": "2204643b50f486423ee7a5877b2bab7d6334cbe62b14b4435fe4ba8a6465ce92"
|
||||
},
|
||||
"go-pkg/internal/models/admin.go": {
|
||||
"captureGroups": 13,
|
||||
"digest": "1a5ec9fd5e752adcfec91cd03b3c2a67c124852228a527002237ec51cd4b39b7"
|
||||
},
|
||||
"go-pkg/internal/models/repository.go": {
|
||||
"captureGroups": 3,
|
||||
"digest": "7de6e11a3cf9c89afa9d89fe37dab85b208699f20105fbade223e4f78a63b15c"
|
||||
},
|
||||
"go-pkg/internal/models/user.go": {
|
||||
"captureGroups": 13,
|
||||
"digest": "e56fcea1c473866ed06fc0262702e54c72016a9556cc6337330c03cc1f638fe1"
|
||||
},
|
||||
"go-pointer-constructor-inference/cmd/main.go": {
|
||||
"captureGroups": 15,
|
||||
"digest": "39f9030e909a37f0e13724f50673dd4a72ba240fb6ac263192ee4feadbc88adf"
|
||||
},
|
||||
"go-pointer-constructor-inference/models/repo.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "ac4799aae638d528c5c7c01c8b9734fc61e995596a12b12e790dcffab97b4029"
|
||||
},
|
||||
"go-pointer-constructor-inference/models/user.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175"
|
||||
},
|
||||
"go-receiver-method-free-call/example.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "2a3c26672d3b997bdc39644361c550f8cf0749489f0945d210e2fb7f3bca9383"
|
||||
},
|
||||
"go-receiver-method-free-call/util.go": {
|
||||
"captureGroups": 4,
|
||||
"digest": "0ac9740c13c851ca101e074bd16422f9da45b8c27db4befbc0c5e7479c2ccdda"
|
||||
},
|
||||
"go-receiver-resolution/cmd/main.go": {
|
||||
"captureGroups": 13,
|
||||
"digest": "e92c59312a46972a6083ba489888b550cdd024b809acbbf367ad025efb844a5c"
|
||||
},
|
||||
"go-receiver-resolution/models/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "1b3acbf48751105d056253488f34159266bdb0b229248c85065e927b72bf4801"
|
||||
},
|
||||
"go-receiver-resolution/models/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "457c76ebf1c86efac7a7d9f384a8e49acd5649c006e6cc8aa29a59c36f0b102a"
|
||||
},
|
||||
"go-return-type-inference/cmd/main.go": {
|
||||
"captureGroups": 39,
|
||||
"digest": "ac8ca3dc1fb7fb4d7a1f947a77e93db890f04d328135dc514f36ba5cd04bc3e1"
|
||||
},
|
||||
"go-return-type-inference/models/repo.go": {
|
||||
"captureGroups": 16,
|
||||
"digest": "8c500db82093b4622acbca734f3040e6176000a9d9a4843e2a81e3f2b3daee7c"
|
||||
},
|
||||
"go-return-type-inference/models/user.go": {
|
||||
"captureGroups": 16,
|
||||
"digest": "a70b19b9c46a02003f26d0b70fa5368983a791583ec3776e983c733a9283eefa"
|
||||
},
|
||||
"go-same-package-factory/main.go": {
|
||||
"captureGroups": 14,
|
||||
"digest": "505d4d279615f3c99457d8cb0f29fdf946b547c6b1fda6d43aa6d4302dd61809"
|
||||
},
|
||||
"go-same-package-factory/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "f8bb213588f517e166b421f19d72cde981153464cc3f3168499769e778c4a22e"
|
||||
},
|
||||
"go-same-package-factory/user.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "4daaad60f35d4519a24cd067baf4cd4bdc421e3dd80b9110a8dfaa8a9e75d9eb"
|
||||
},
|
||||
"go-split-method-owner/main.go": {
|
||||
"captureGroups": 9,
|
||||
"digest": "e9c105208ad6ef2f087f758972475aa403294e886751d75113b4ca45fa4e0b8a"
|
||||
},
|
||||
"go-split-method-owner/repo.go": {
|
||||
"captureGroups": 8,
|
||||
"digest": "f8bb213588f517e166b421f19d72cde981153464cc3f3168499769e778c4a22e"
|
||||
},
|
||||
"go-split-method-owner/save.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "6e0e1b5521a2fad1cd00252e771926d5caa7930440278968f7f8607821ad339c"
|
||||
},
|
||||
"go-split-method-owner/user.go": {
|
||||
"captureGroups": 3,
|
||||
"digest": "827dc0208b47776976313a4560fbd250ab7fa521ae46581213a79fc15fd52ce8"
|
||||
},
|
||||
"go-struct-literals/app.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "97ec29ec0e0dc1f23a804ff6808023a32eafdeb00f194d3bea9b50ed54c7f258"
|
||||
},
|
||||
"go-struct-literals/user.go": {
|
||||
"captureGroups": 10,
|
||||
"digest": "89b791a9d150fe341924d54d3173d9f35f72be899d0e7ac03a35d32119b94f8f"
|
||||
},
|
||||
"go-type-assertion/main.go": {
|
||||
"captureGroups": 11,
|
||||
"digest": "b8bd327d3965531802a93bc01e3c24969a027540397a1635119ef0b7da46c348"
|
||||
},
|
||||
"go-type-assertion/models.go": {
|
||||
"captureGroups": 17,
|
||||
"digest": "3780f8f7c145a15f849ae6958e492db0044e825b81aa8dfd21b9873759fad65e"
|
||||
},
|
||||
"go-variadic-resolution/cmd/main.go": {
|
||||
"captureGroups": 6,
|
||||
"digest": "443f9736b9c67df30fe97d8353ddc2584de4c88ba698e62878683d5d77362d89"
|
||||
},
|
||||
"go-variadic-resolution/internal/logger/logger.go": {
|
||||
"captureGroups": 4,
|
||||
"digest": "83b987f527f95e360966793148d07e569fa02fdc5e4febc09daa51cfce16b114"
|
||||
},
|
||||
"go-write-access/main.go": {
|
||||
"captureGroups": 20,
|
||||
"digest": "92fdad46b6933fd78dcbb19677a720f037306a4f03fedcca9156b54c1c9bf994"
|
||||
},
|
||||
"synthetic:dao-20": {
|
||||
"captureGroups": 481,
|
||||
"digest": "1698b5dd78c8094f251b10ab8cacebfbf453f38eddf7233fbc7964e32f04ceeb"
|
||||
}
|
||||
}
|
||||
+10
@@ -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
@@ -0,0 +1,6 @@
|
||||
namespace MyApp.Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
Vendored
+13
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MyApp.Legacy;
|
||||
|
||||
public class Tasks
|
||||
{
|
||||
public void Run() { }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MyApp.Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>MyApp</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1534,3 +1534,82 @@ describe('CLI end-to-end', () => {
|
||||
}, 35000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── impact disambiguation flags reach the backend at runtime (#1907 U2) ──
|
||||
// The mocked unit test proves the CLI option → callTool param mapping; this
|
||||
// proves the flags survive the real Commander → lazy-action → impactCommand →
|
||||
// callTool chain by spawning the actual CLI. The F2 gap is *flag-forwarding*,
|
||||
// so a uniquely-named fixture symbol is enough — no ambiguous fixture needed.
|
||||
// Tests self-skip when the environment cannot index the fixture (e.g. a
|
||||
// worktree without the built parse-worker); CI validates the real path.
|
||||
describe('impact disambiguation flags reach the backend (e2e, #1907)', () => {
|
||||
const SYMBOL = 'formatResponse'; // uniquely named, in mini-repo/src/formatter.ts
|
||||
let uid: string | undefined;
|
||||
let symbolFile: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
// Idempotent: the earlier analyze test may already have indexed mini-repo.
|
||||
runCli('analyze', MINI_REPO, 60000);
|
||||
// Derive the real uid + filePath from context so the test is robust to the
|
||||
// exact uid format rather than hard-coding `Function:<path>:<name>`.
|
||||
const ctx = runCliRaw(['context', SYMBOL, '--repo', 'mini-repo'], MINI_REPO, 30000);
|
||||
if (ctx.status === 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(ctx.stdout.trim());
|
||||
uid = parsed?.symbol?.uid;
|
||||
symbolFile = parsed?.symbol?.filePath;
|
||||
} catch {
|
||||
/* leave undefined → tests self-skip below */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards --uid alone with no positional target (U1 + --uid end-to-end)', () => {
|
||||
if (!uid) return; // environment could not index — validated in CI
|
||||
const res = runCliRaw(['impact', '--uid', uid, '--repo', 'mini-repo'], MINI_REPO, 30000);
|
||||
if (res.status === null) return;
|
||||
expect(res.status).toBe(0);
|
||||
const out = JSON.parse(res.stdout.trim());
|
||||
expect(out).not.toHaveProperty('error');
|
||||
expect(out.target?.id).toBe(uid);
|
||||
});
|
||||
|
||||
it('forwards --file: the correct file resolves, a wrong file does not (negative control)', () => {
|
||||
if (!uid || !symbolFile) return;
|
||||
|
||||
const ok = runCliRaw(
|
||||
['impact', SYMBOL, '--file', symbolFile, '--repo', 'mini-repo'],
|
||||
MINI_REPO,
|
||||
30000,
|
||||
);
|
||||
if (ok.status === null) return;
|
||||
expect(ok.status).toBe(0);
|
||||
const okOut = JSON.parse(ok.stdout.trim());
|
||||
expect(okOut.status).not.toBe('ambiguous');
|
||||
expect(okOut.target?.filePath).toBe(symbolFile);
|
||||
|
||||
// Wrong --file hint → CONTAINS matches nothing → must NOT resolve to the
|
||||
// formatter.ts symbol. Proves the --file value reached the resolver.
|
||||
const wrong = runCliRaw(
|
||||
['impact', SYMBOL, '--file', 'does/not/exist/nowhere.ts', '--repo', 'mini-repo'],
|
||||
MINI_REPO,
|
||||
30000,
|
||||
);
|
||||
if (wrong.status === null) return;
|
||||
const wrongOut = JSON.parse(wrong.stdout.trim());
|
||||
expect(wrongOut.error !== undefined || wrongOut.target?.filePath !== symbolFile).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards --kind: exit 0 with the kind hint applied', () => {
|
||||
if (!uid) return;
|
||||
const res = runCliRaw(
|
||||
['impact', SYMBOL, '--kind', 'Function', '--repo', 'mini-repo'],
|
||||
MINI_REPO,
|
||||
30000,
|
||||
);
|
||||
if (res.status === null) return;
|
||||
expect(res.status).toBe(0);
|
||||
const out = JSON.parse(res.stdout.trim());
|
||||
expect(out).not.toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Go ingestion pipeline benchmark.
|
||||
*
|
||||
* Generates synthetic Go codebases at increasing scales and measures
|
||||
* wall-clock time and peak heap through the full pipeline — parsing,
|
||||
* Go scope capture (emitGoScopeCaptures), package/import resolution, and
|
||||
* call resolution.
|
||||
*
|
||||
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/go-pipeline-benchmark.test.ts
|
||||
*
|
||||
* The first suite ("scales with file count") generates many small files —
|
||||
* each a struct plus getter/setter/compute methods, the DAO-style shape that
|
||||
* stresses the Go scope-capture path. Run under vitest it falls back to the
|
||||
* sequential path (no compiled worker), so it measures parse + scope-capture
|
||||
* scaling without the worker pool.
|
||||
*
|
||||
* The second suite ("worker pool — issue #1848") reproduces the actual bug:
|
||||
* it points the pool at the COMPILED `dist/.../parse-worker.js` (real
|
||||
* worker_threads), generates one ~400 KiB generated-DAO file plus padding so
|
||||
* the 15-file worker threshold trips, and runs with a short sub-batch idle
|
||||
* timeout. Under the buggy code the worker looks idle while inside
|
||||
* emitGoScopeCaptures and the file is quarantined; the fix emits progress so
|
||||
* the file survives. Requires a build first:
|
||||
*
|
||||
* (cd gitnexus && npm run build)
|
||||
* GITNEXUS_BENCH=1 npx vitest run test/integration/go-pipeline-benchmark.test.ts
|
||||
*
|
||||
* The worker suite auto-skips if the compiled worker is absent.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import { emitGoScopeCaptures } from '../../src/core/ingestion/languages/go/index.js';
|
||||
|
||||
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
|
||||
|
||||
const MODULE_PATH = 'example.com/go-bench';
|
||||
|
||||
/**
|
||||
* The compiled worker the pool spawns. Under vitest, `import.meta.url`
|
||||
* resolves to `src/`, where no `.js` exists — so we point straight at the
|
||||
* `dist/` build, the same fallback parse-impl uses. `null` when unbuilt.
|
||||
*/
|
||||
const DIST_WORKER_URL = new URL(
|
||||
'../../dist/core/ingestion/workers/parse-worker.js',
|
||||
import.meta.url,
|
||||
);
|
||||
const DIST_WORKER_AVAILABLE = fs.existsSync(fileURLToPath(DIST_WORKER_URL));
|
||||
|
||||
interface BenchResult {
|
||||
fileCount: number;
|
||||
structCount: number;
|
||||
packageCount: number;
|
||||
elapsedMs: number;
|
||||
peakHeapMB: number;
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
function generateGoFixture(
|
||||
fileCount: number,
|
||||
packageCount: number,
|
||||
): { dir: string; structCount: number; packageCount: number } {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `go-bench-${fileCount}-`));
|
||||
fs.writeFileSync(path.join(dir, 'go.mod'), `module ${MODULE_PATH}\n\ngo 1.22\n`);
|
||||
|
||||
const packages: string[] = [];
|
||||
for (let i = 0; i < packageCount; i++) {
|
||||
packages.push(`pkg${i}`);
|
||||
}
|
||||
|
||||
const structCount = fileCount;
|
||||
const createdPackages = new Set<string>();
|
||||
|
||||
for (let f = 0; f < fileCount; f++) {
|
||||
const pkg = packages[f % packages.length];
|
||||
const pkgDir = path.join(dir, pkg);
|
||||
if (!createdPackages.has(pkg)) {
|
||||
fs.mkdirSync(pkgDir, { recursive: true });
|
||||
createdPackages.add(pkg);
|
||||
}
|
||||
|
||||
const structName = `Item${f}`;
|
||||
|
||||
const siblingIdx = (f + 1) % fileCount;
|
||||
const siblingStruct = `Item${siblingIdx}`;
|
||||
const siblingPkg = packages[siblingIdx % packages.length];
|
||||
|
||||
const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount;
|
||||
const crossStruct = `Item${crossIdx}`;
|
||||
const crossPkg = packages[crossIdx % packages.length];
|
||||
|
||||
// Only import packages we actually reference, and never our own.
|
||||
const imports = new Set<string>();
|
||||
if (siblingPkg !== pkg) imports.add(siblingPkg);
|
||||
if (crossPkg !== pkg) imports.add(crossPkg);
|
||||
|
||||
const importBlock =
|
||||
imports.size > 0
|
||||
? ['import (', ...[...imports].map((p) => `\t"${MODULE_PATH}/${p}"`), ')', '']
|
||||
: [];
|
||||
|
||||
const qualify = (otherPkg: string, name: string) =>
|
||||
otherPkg === pkg ? name : `${otherPkg}.${name}`;
|
||||
|
||||
const siblingRef = qualify(siblingPkg, siblingStruct);
|
||||
const siblingCtor = qualify(siblingPkg, `New${siblingStruct}`);
|
||||
const crossRef = qualify(crossPkg, crossStruct);
|
||||
const crossCtor = qualify(crossPkg, `New${crossStruct}`);
|
||||
|
||||
const content = [
|
||||
`package ${pkg}`,
|
||||
'',
|
||||
...importBlock,
|
||||
`type ${structName} struct {`,
|
||||
`\tID int64`,
|
||||
`\tName string`,
|
||||
`\tEmail string`,
|
||||
`\tValue float64`,
|
||||
`}`,
|
||||
'',
|
||||
`func New${structName}(id int64, name string) *${structName} {`,
|
||||
`\treturn &${structName}{ID: id, Name: name}`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) GetID() int64 {`,
|
||||
`\treturn i.ID`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) SetID(id int64) {`,
|
||||
`\ti.ID = id`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) GetName() string {`,
|
||||
`\treturn i.Name`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) SetValue(v float64) {`,
|
||||
`\ti.Value = v`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) Compute() float64 {`,
|
||||
`\treturn i.Value * float64(i.ID)`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) Process() *${siblingRef} {`,
|
||||
`\tsibling := ${siblingCtor}(i.ID, i.Name)`,
|
||||
`\tsibling.SetValue(i.Compute())`,
|
||||
`\treturn sibling`,
|
||||
`}`,
|
||||
'',
|
||||
`func (i *${structName}) CrossCall() *${crossRef} {`,
|
||||
`\tcross := ${crossCtor}(i.ID, i.Name)`,
|
||||
`\t_ = cross.GetID()`,
|
||||
`\treturn cross`,
|
||||
`}`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(path.join(pkgDir, `item${f}.go`), content);
|
||||
}
|
||||
|
||||
return { dir, structCount, packageCount: createdPackages.size };
|
||||
}
|
||||
|
||||
async function runBenchmark(
|
||||
fileCount: number,
|
||||
packageCount: number,
|
||||
budgetMs: number,
|
||||
): Promise<BenchResult> {
|
||||
const {
|
||||
dir,
|
||||
structCount,
|
||||
packageCount: actualPackages,
|
||||
} = generateGoFixture(fileCount, packageCount);
|
||||
|
||||
let peakHeapMB = 0;
|
||||
const heapSampler = setInterval(() => {
|
||||
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
|
||||
if (heap > peakHeapMB) peakHeapMB = heap;
|
||||
}, 50);
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const start = Date.now();
|
||||
const result = await Promise.race([
|
||||
runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }),
|
||||
new Promise<never>((_, reject) => {
|
||||
// Hold the handle so the winning (pipeline) path can cancel it in the
|
||||
// finally — otherwise the timer lingers for up to budgetMs and its
|
||||
// late rejection surfaces as an unhandled promise rejection.
|
||||
timeoutHandle = setTimeout(
|
||||
() => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)),
|
||||
budgetMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
return {
|
||||
fileCount,
|
||||
structCount,
|
||||
packageCount: actualPackages,
|
||||
elapsedMs,
|
||||
peakHeapMB: Math.round(peakHeapMB),
|
||||
nodeCount: result.graph.nodeCount,
|
||||
edgeCount: result.graph.relationshipCount,
|
||||
};
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
|
||||
clearInterval(heapSampler);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function printResults(label: string, results: BenchResult[]) {
|
||||
console.log(`\n${label}`);
|
||||
console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐');
|
||||
console.log('│ Files │ Structs │ Packages │ Time (ms) │ Heap MB │ Nodes │ Edges │');
|
||||
console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤');
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
`│ ${String(r.fileCount).padStart(8)} │ ${String(r.structCount).padStart(7)} │ ${String(r.packageCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`,
|
||||
);
|
||||
}
|
||||
console.log('└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┘');
|
||||
|
||||
if (results.length >= 2) {
|
||||
console.log('\nScaling ratios (time_ratio / file_ratio):');
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
|
||||
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
|
||||
const scaling = timeRatio / fileRatio;
|
||||
console.log(
|
||||
` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the issue #1848 reproduction fixture: one large generated-DAO Go
|
||||
* file (`package generated`, struct + 7 methods per entity) plus `padCount`
|
||||
* trivial files so parse-impl crosses the 15-file worker threshold and the
|
||||
* real worker pool engages.
|
||||
*/
|
||||
function generateGoQuarantineFixture(
|
||||
entityCount: number,
|
||||
padCount: number,
|
||||
): { dir: string; bigFileBytes: number; fileCount: number } {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `go-bench-1848-${entityCount}-`));
|
||||
|
||||
const lines = [
|
||||
'package generated',
|
||||
'',
|
||||
'// Code generated for GitNexus issue #1848 repro. DO NOT EDIT.',
|
||||
'',
|
||||
];
|
||||
for (let i = 0; i < entityCount; i++) {
|
||||
const n = String(i).padStart(4, '0');
|
||||
lines.push(`type DefUserDao${n} struct {`);
|
||||
lines.push('\tid int64');
|
||||
lines.push('\tname string');
|
||||
lines.push('\temail string');
|
||||
lines.push('\tcreatedAt int64');
|
||||
lines.push('}', '');
|
||||
lines.push(`func (d *DefUserDao${n}) GetID() int64 { return d.id }`);
|
||||
lines.push(`func (d *DefUserDao${n}) SetID(id int64) { d.id = id }`);
|
||||
lines.push(`func (d *DefUserDao${n}) GetName() string { return d.name }`);
|
||||
lines.push(`func (d *DefUserDao${n}) SetName(name string) { d.name = name }`);
|
||||
lines.push(`func (d *DefUserDao${n}) GetEmail() string { return d.email }`);
|
||||
lines.push(`func (d *DefUserDao${n}) SetEmail(email string) { d.email = email }`);
|
||||
lines.push(`func (d *DefUserDao${n}) Validate() error { return nil }`);
|
||||
lines.push('');
|
||||
}
|
||||
const bigContent = lines.join('\n');
|
||||
fs.writeFileSync(path.join(dir, 'zz_generated.def_userdao.go'), bigContent);
|
||||
|
||||
for (let i = 0; i < padCount; i++) {
|
||||
const idx = String(i).padStart(2, '0');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `pad_${idx}.go`),
|
||||
`package pad${i}\n\nfunc Ping${i}() int { return ${i} }\n`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
dir,
|
||||
bigFileBytes: Buffer.byteLength(bigContent),
|
||||
fileCount: 1 + padCount,
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!BENCH_ENABLED)('Go pipeline benchmark', () => {
|
||||
it('scales with file count (workers enabled)', async () => {
|
||||
const scales = [100, 250, 500];
|
||||
const results: BenchResult[] = [];
|
||||
|
||||
for (const fileCount of scales) {
|
||||
const packageCount = Math.max(4, Math.ceil(Math.sqrt(fileCount)));
|
||||
const result = await runBenchmark(fileCount, packageCount, 180_000);
|
||||
results.push(result);
|
||||
console.log(
|
||||
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
|
||||
);
|
||||
}
|
||||
|
||||
printResults('Go Pipeline — Workers Enabled', results);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
|
||||
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
|
||||
// The scale steps are 2.5x (100->250) and 2x (250->500). A quadratic
|
||||
// regression makes timeRatio ~= fileRatio^2, i.e. timeRatio/fileRatio ~=
|
||||
// fileRatio (2.5 and 2.0) — which a `< 3` bound would wave through. The
|
||||
// O(n) path keeps this ratio ~1 (measured 0.44 and 0.75 post-fix), so a
|
||||
// `< 1.5` bound (the printResults "linear" boundary) actually fails on a
|
||||
// re-regression to O(n^2) while leaving comfortable headroom for linear.
|
||||
expect(timeRatio / fileRatio).toBeLessThan(1.5);
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
describe.skipIf(!BENCH_ENABLED || !DIST_WORKER_AVAILABLE)(
|
||||
'Go pipeline benchmark — worker pool (issue #1848)',
|
||||
() => {
|
||||
if (BENCH_ENABLED && !DIST_WORKER_AVAILABLE) {
|
||||
// Surfaced once when the bench is requested but the worker is unbuilt.
|
||||
console.warn(
|
||||
`\n[go-bench] Skipping worker-pool suite: compiled worker not found at\n ${fileURLToPath(DIST_WORKER_URL)}\n Build first: (cd gitnexus && npm run build)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Tunables mirror run-analyze-repro.sh. 800 entities ≈ 406 KiB — under the
|
||||
// 512 KiB GITNEXUS_MAX_FILE_SIZE ceiling so the file is parsed, not skipped.
|
||||
const entityCount = Number(process.env.REPRO_GO_ENTITIES ?? 800);
|
||||
// 30 s reproduces on the report author's machine; raising it (e.g. 120000)
|
||||
// is the documented workaround. Kept overridable so the same test can both
|
||||
// reproduce the bug and confirm the fix.
|
||||
const subBatchTimeoutMs = Number(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS ?? 30_000);
|
||||
|
||||
it('does not quarantine the large generated Go file on sub-batch idle timeout', async () => {
|
||||
const { dir, bigFileBytes, fileCount } = generateGoQuarantineFixture(entityCount, 14);
|
||||
|
||||
// Sub-batch knobs that force fine chunking onto the worker (env-only —
|
||||
// there is no PipelineOptions field for these two). Capture the prior
|
||||
// values before the try; set them as the first statements INSIDE it so
|
||||
// the finally's restore covers every path that mutated the process env.
|
||||
const prevMaxBytes = process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES;
|
||||
const prevTimeout = process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS;
|
||||
|
||||
let peakHeapMB = 0;
|
||||
const heapSampler = setInterval(() => {
|
||||
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
|
||||
if (heap > peakHeapMB) peakHeapMB = heap;
|
||||
}, 50);
|
||||
|
||||
try {
|
||||
process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES = '262144';
|
||||
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(subBatchTimeoutMs);
|
||||
const start = Date.now();
|
||||
const result = await runPipelineFromRepo(dir, () => {}, {
|
||||
skipGraphPhases: true,
|
||||
// Real worker_threads against the compiled worker — the surface the
|
||||
// bug actually lives on.
|
||||
workerUrlForTest: DIST_WORKER_URL,
|
||||
// Match the repro's chunking so the byte budget mirrors the issue.
|
||||
chunkByteBudget: 262144,
|
||||
parseChunkConcurrency: 1,
|
||||
});
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
// The big file alone emits ≥ entityCount*5 nodes (1 struct + 7 methods
|
||||
// each). If the worker is quarantined on the idle timeout, those
|
||||
// vanish — so this threshold is the regression guard.
|
||||
const survivalFloor = entityCount * 5;
|
||||
const survived = result.graph.nodeCount >= survivalFloor;
|
||||
|
||||
console.log(
|
||||
`\nGo Pipeline — Worker Pool (issue #1848)` +
|
||||
`\n files: ${fileCount} (1 big @ ${Math.round(bigFileBytes / 1024)} KiB + 14 pad)` +
|
||||
`\n entities: ${entityCount}, sub-batch idle timeout: ${subBatchTimeoutMs}ms` +
|
||||
`\n elapsed: ${elapsedMs}ms, peak heap: ${Math.round(peakHeapMB)}MB` +
|
||||
`\n usedWorkerPool: ${result.usedWorkerPool}` +
|
||||
`\n nodes: ${result.graph.nodeCount} (survival floor ${survivalFloor}) → ${survived ? 'SURVIVED' : 'QUARANTINED (bug reproduced)'}`,
|
||||
);
|
||||
|
||||
// Sanity: the worker path must have actually engaged, else the test
|
||||
// proves nothing about the worker bug.
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
// The fix: the generated file is fully parsed despite the idle timeout.
|
||||
expect(result.graph.nodeCount).toBeGreaterThanOrEqual(survivalFloor);
|
||||
} finally {
|
||||
clearInterval(heapSampler);
|
||||
if (prevMaxBytes === undefined) delete process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES;
|
||||
else process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES = prevMaxBytes;
|
||||
if (prevTimeout === undefined) delete process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS;
|
||||
else process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = prevTimeout;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 360_000);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Unlike the two suites above, this one is NOT gated behind GITNEXUS_BENCH and
|
||||
* needs no compiled worker — so it runs in normal CI and is the actual guard
|
||||
* against an O(n^2) re-regression of emitGoScopeCaptures (issue #1848). It calls
|
||||
* the hotpath directly on a ~400-struct generated source. The O(n) path does
|
||||
* this in a few hundred ms; the old findNodeAtRange-from-root behaviour took
|
||||
* ~25s+ at this size. The budget is a coarse tripwire (huge margin over the
|
||||
* fixed path, far below a quadratic regression), not a microbenchmark — keep it
|
||||
* generous so it never flakes on a loaded CI runner.
|
||||
*/
|
||||
describe('Go scope-capture O(n^2) regression tripwire', () => {
|
||||
function generateGoStructSource(structCount: number): string {
|
||||
const lines = ['package generated', ''];
|
||||
for (let i = 0; i < structCount; i++) {
|
||||
const n = String(i).padStart(4, '0');
|
||||
lines.push(
|
||||
`type Item${n} struct {`,
|
||||
'\tid int64',
|
||||
'\tname string',
|
||||
'}',
|
||||
'',
|
||||
`func (d *Item${n}) GetID() int64 { return d.id }`,
|
||||
`func (d *Item${n}) SetID(id int64) { d.id = id }`,
|
||||
`func (d *Item${n}) GetName() string { return d.name }`,
|
||||
`func (d *Item${n}) Validate() error { return nil }`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
it('parses a 400-struct file in well under the O(n^2) tripwire budget', () => {
|
||||
const STRUCT_COUNT = 400;
|
||||
const BUDGET_MS = 5_000; // coarse: ~20x the fixed path (~250ms), trips a ~20x regression; a quadratic regression at 400 structs is ~25s
|
||||
const src = generateGoStructSource(STRUCT_COUNT);
|
||||
|
||||
emitGoScopeCaptures(src, 'tripwire-warmup.go'); // warm up the parser/query JIT
|
||||
|
||||
const start = Date.now();
|
||||
const matches = emitGoScopeCaptures(src, 'tripwire.go');
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
// Sanity: the captures are actually produced (each struct + 4 methods emits
|
||||
// far more than 10 capture groups), so a fast-but-empty result can't pass.
|
||||
expect(matches.length).toBeGreaterThan(STRUCT_COUNT * 10);
|
||||
// The actual regression guard: a re-regression to O(n^2) blows this budget.
|
||||
expect(elapsedMs).toBeLessThan(BUDGET_MS);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -302,6 +302,116 @@ withTestLbugDB(
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── impact disambiguation + label-scoped resolution (#1907) ─────────
|
||||
// Covers the disambiguation surface the CLI --uid/--file/--kind flags
|
||||
// wire through to, and guards the label-scoped resolver against the
|
||||
// binder failure that motivated the fix.
|
||||
describe('impact disambiguation (#1907)', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(async () => {
|
||||
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
||||
if (!ext._backend) {
|
||||
throw new Error(
|
||||
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
||||
);
|
||||
}
|
||||
backend = ext._backend;
|
||||
});
|
||||
|
||||
it('reports an ambiguous target with disambiguation guidance', async () => {
|
||||
// Two Methods named 'authenticate' (AuthService + BaseService).
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'authenticate',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).toBe('ambiguous');
|
||||
expect(result.message).toMatch(/disambiguate/i);
|
||||
const uids = (result.candidates ?? []).map((c: any) => c.uid);
|
||||
expect(uids).toContain('method:AuthService.authenticate');
|
||||
expect(uids).toContain('method:BaseService.authenticate');
|
||||
});
|
||||
|
||||
it('resolves the ambiguous target via target_uid (the --uid flag path)', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'authenticate',
|
||||
target_uid: 'method:BaseService.authenticate',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).not.toBe('ambiguous');
|
||||
// target_uid selects the exact symbol, bypassing the name ranker.
|
||||
expect(result.target?.id).toBe('method:BaseService.authenticate');
|
||||
expect(result.target?.filePath).toBe('src/base.ts');
|
||||
});
|
||||
|
||||
it('resolves the ambiguous target via file_path (the --file flag path)', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'authenticate',
|
||||
file_path: 'src/base.ts',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).not.toBe('ambiguous');
|
||||
expect(result.target?.id).toBe('method:BaseService.authenticate');
|
||||
});
|
||||
|
||||
it('does not crash when a name collides across symbol and non-symbol labels', async () => {
|
||||
// 'alpha' exists as both a Function and a Tool sharing src/tools.py.
|
||||
// The Tool node table has no startLine/endLine columns, so the
|
||||
// resolver's `RETURN n.startLine` projection only binds because the
|
||||
// candidate set also contains a label that *does* have those columns
|
||||
// (lenient multi-table binding). This guards that the disambiguation
|
||||
// path keeps tolerating non-symbol node types — and would catch a
|
||||
// future naive label-scoping that reintroduces the #1907 binder error
|
||||
// ("Cannot find property … for n") by matching property-poor tables
|
||||
// in isolation.
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'alpha',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).toBe('ambiguous');
|
||||
const uids = (result.candidates ?? []).map((c: any) => c.uid);
|
||||
expect(uids).toContain('func:alpha');
|
||||
expect(uids).toContain('Tool:alpha');
|
||||
});
|
||||
|
||||
it('context resolves the same cross-label collision without crashing', async () => {
|
||||
const result = await backend.callTool('context', { name: 'alpha' });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).toBe('ambiguous');
|
||||
const uids = (result.candidates ?? []).map((c: any) => c.uid);
|
||||
expect(uids).toContain('func:alpha');
|
||||
// Assert the non-symbol Tool node stays in the candidate set, not just
|
||||
// that nothing crashed — a regression that silently dropped Tool from
|
||||
// the lenient-binding match would otherwise pass the non-crash check.
|
||||
expect(uids).toContain('Tool:alpha');
|
||||
});
|
||||
|
||||
it('ranks the kind-matching candidate first when kind is supplied (the --kind flag path)', async () => {
|
||||
// 'alpha' is both a Function (func:alpha) and a Tool (Tool:alpha).
|
||||
// kind only adds +0.20 in scoreCandidate, so 0.50 + 0.20 = 0.70 stays
|
||||
// below the 0.95 confident-resolution threshold — the response is still
|
||||
// ambiguous. What kind buys is ranking: the Function is promoted above
|
||||
// the non-matching Tool. This exercises the scoreCandidate kind branch
|
||||
// against a real DB rather than only through the mocked CLI unit test.
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'alpha',
|
||||
kind: 'Function',
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).toBe('ambiguous');
|
||||
const candidates = result.candidates ?? [];
|
||||
expect(candidates[0]?.uid).toBe('func:alpha');
|
||||
expect(candidates[0]?.kind).toBe('Function');
|
||||
const tool = candidates.find((c: any) => c.uid === 'Tool:alpha');
|
||||
expect(candidates[0]?.score).toBeGreaterThan(tool?.score);
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: LOCAL_BACKEND_SEED_DATA,
|
||||
@@ -327,3 +437,68 @@ withTestLbugDB(
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// ─── impact BFS bound parameters (#1907 review F5) ───────────────────────
|
||||
// Isolated DB (not the shared seed) with a frontier node whose id contains a
|
||||
// single quote. Under the old string-interpolated query this id had to be
|
||||
// hand-escaped; the parameterized query (executeParameterized with bound
|
||||
// $frontierIds/$relTypes) carries it as data. Guards that a quote-bearing id
|
||||
// traverses without a Prepare/parser error, and that a no-caller symbol
|
||||
// returns an empty result rather than erroring.
|
||||
withTestLbugDB(
|
||||
'local-backend-impact-param',
|
||||
(handle) => {
|
||||
describe('impact BFS bound parameters (#1907 F5)', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(() => {
|
||||
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
||||
if (!ext._backend) {
|
||||
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend');
|
||||
}
|
||||
backend = ext._backend;
|
||||
});
|
||||
|
||||
it('traverses a caller whose id contains a single quote without a query error', async () => {
|
||||
const result = await backend.callTool('impact', { target: 'sink', direction: 'upstream' });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || [];
|
||||
const callerIds = d1.map((d: any) => d.uid ?? d.id);
|
||||
expect(callerIds).toContain("func:o'd");
|
||||
});
|
||||
|
||||
it('returns an empty result (not an error) for a symbol with no callers', async () => {
|
||||
const result = await backend.callTool('impact', {
|
||||
target: 'sink',
|
||||
direction: 'downstream',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.impactedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: [
|
||||
`CREATE (a:Function {id: "func:o'd", name: 'odd', filePath: 'src/q.ts', startLine: 1, endLine: 3, isExported: true, content: 'function odd() {}', description: 'caller with a quote in its id'})`,
|
||||
`CREATE (b:Function {id: 'func:sink', name: 'sink', filePath: 'src/q.ts', startLine: 5, endLine: 8, isExported: true, content: 'function sink() {}', description: 'callee'})`,
|
||||
`MATCH (a:Function), (b:Function) WHERE a.id = "func:o'd" AND b.id = 'func:sink'
|
||||
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
|
||||
],
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'param-repo',
|
||||
path: '/param/repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
|
||||
},
|
||||
]);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as any)._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -649,19 +649,26 @@ describe('LocalBackend.callTool', () => {
|
||||
|
||||
it('impact byDepth items include a processes field (default empty when no processes)', async () => {
|
||||
// Resolver returns target; BFS returns one frontier caller; no STEP_IN_PROCESS rows.
|
||||
(executeParameterized as any).mockResolvedValue([
|
||||
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||||
]);
|
||||
(executeQuery as any).mockResolvedValue([
|
||||
{
|
||||
id: 'func:caller',
|
||||
name: 'caller',
|
||||
type: 'Function',
|
||||
filePath: 'src/uses-main.ts',
|
||||
relType: 'CALLS',
|
||||
confidence: 0.9,
|
||||
},
|
||||
]);
|
||||
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
|
||||
// BFS frontier query is now parameterized (#1907 U3).
|
||||
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'func:caller',
|
||||
name: 'caller',
|
||||
type: 'Function',
|
||||
filePath: 'src/uses-main.ts',
|
||||
relType: 'CALLS',
|
||||
confidence: 0.9,
|
||||
},
|
||||
]);
|
||||
}
|
||||
// Symbol resolution.
|
||||
return Promise.resolve([
|
||||
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||||
]);
|
||||
});
|
||||
(executeQuery as any).mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
|
||||
const d1 = result.byDepth?.[1] || result.byDepth?.['1'] || [];
|
||||
@@ -674,6 +681,19 @@ describe('LocalBackend.callTool', () => {
|
||||
|
||||
it('impact populates byDepth processes when STEP_IN_PROCESS rows exist', async () => {
|
||||
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
|
||||
// BFS frontier query is now parameterized (#1907 U3).
|
||||
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'func:caller',
|
||||
name: 'caller',
|
||||
type: 'Function',
|
||||
filePath: 'src/uses-main.ts',
|
||||
relType: 'CALLS',
|
||||
confidence: 0.9,
|
||||
},
|
||||
]);
|
||||
}
|
||||
// Symbol resolver name-lookup
|
||||
if (cypher.includes('WHERE n.name =')) {
|
||||
return Promise.resolve([
|
||||
@@ -739,6 +759,20 @@ describe('LocalBackend.callTool', () => {
|
||||
it('impact summaryOnly:true skips the per-symbol STEP_IN_PROCESS enrichment pass', async () => {
|
||||
// Resolver returns target; BFS returns one caller; aggregation returns one process row.
|
||||
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
|
||||
// BFS frontier query is now parameterized (#1907 U3) — return a caller so
|
||||
// the per-symbol-skip assertion below is meaningful (not vacuous).
|
||||
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'func:caller',
|
||||
name: 'caller',
|
||||
type: 'Function',
|
||||
filePath: 'src/a.ts',
|
||||
relType: 'CALLS',
|
||||
confidence: 0.9,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (cypher.includes('WHERE n.name =')) {
|
||||
return Promise.resolve([
|
||||
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
|
||||
@@ -811,6 +845,19 @@ describe('LocalBackend.callTool', () => {
|
||||
await backend.init();
|
||||
|
||||
(executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => {
|
||||
// BFS frontier query is now parameterized (#1907 U3).
|
||||
if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'func:caller',
|
||||
name: 'caller',
|
||||
type: 'Function',
|
||||
filePath: 'src/uses-main.ts',
|
||||
relType: 'CALLS',
|
||||
confidence: 0.9,
|
||||
},
|
||||
]);
|
||||
}
|
||||
// UID resolver
|
||||
if (cypher.includes('WHERE n.id = $uid')) {
|
||||
return Promise.resolve([
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Unit Tests: CLI `impact` disambiguation flag wiring (#1907)
|
||||
*
|
||||
* The CLI `impact` command gained --uid / --file / --kind so that, when impact
|
||||
* reports an `ambiguous` target, users can follow the "disambiguate" guidance
|
||||
* straight from the terminal (previously only the MCP tool accepted these).
|
||||
* These tests pin that impactCommand forwards the flags to
|
||||
* callTool('impact', …) under the backend's parameter names
|
||||
* (target_uid / file_path / kind) — the same names the MCP impact tool uses.
|
||||
*
|
||||
* The LocalBackend is fully mocked: this isolates the CLI option → tool param
|
||||
* mapping from any graph/DB behaviour.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { callTool, init } = vi.hoisted(() => ({
|
||||
callTool: vi.fn(),
|
||||
init: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/mcp/local/local-backend.js', () => ({
|
||||
LocalBackend: class {
|
||||
init = init;
|
||||
callTool = callTool;
|
||||
},
|
||||
// U4: impactCommand imports VALID_NODE_LABELS to soft-validate --kind.
|
||||
VALID_NODE_LABELS: new Set(['Function', 'Class', 'Interface', 'Method', 'Constructor']),
|
||||
}));
|
||||
|
||||
// impactCommand prints its result via fs.writeSync(fd 1, …). Silence that so
|
||||
// the assertion-only test does not write JSON to the runner's stdout. tool.ts
|
||||
// uses only writeSync from node:fs, so a full mock is safe here (matches the
|
||||
// pattern in tool-direct-cli.test.ts).
|
||||
vi.mock('node:fs', () => ({
|
||||
writeSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { impactCommand } from '../../src/cli/tool.js';
|
||||
|
||||
describe('CLI impact disambiguation flags (#1907)', () => {
|
||||
beforeEach(() => {
|
||||
callTool.mockReset();
|
||||
callTool.mockResolvedValue({ status: 'found', impactedCount: 0 });
|
||||
});
|
||||
|
||||
it('forwards --uid/--file/--kind as target_uid/file_path/kind', async () => {
|
||||
await impactCommand('get_embeddings', {
|
||||
direction: 'upstream',
|
||||
uid: 'Function:isma/scripts/ingest_md_file.py:get_embeddings',
|
||||
file: 'isma/scripts/ingest_md_file.py',
|
||||
kind: 'Function',
|
||||
});
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1);
|
||||
expect(callTool).toHaveBeenCalledWith(
|
||||
'impact',
|
||||
expect.objectContaining({
|
||||
target: 'get_embeddings',
|
||||
target_uid: 'Function:isma/scripts/ingest_md_file.py:get_embeddings',
|
||||
file_path: 'isma/scripts/ingest_md_file.py',
|
||||
kind: 'Function',
|
||||
direction: 'upstream',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves disambiguation params undefined when no flags are supplied', async () => {
|
||||
await impactCommand('AuthService', { direction: 'upstream' });
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1);
|
||||
const params = callTool.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(params.target).toBe('AuthService');
|
||||
expect(params.target_uid).toBeUndefined();
|
||||
expect(params.file_path).toBeUndefined();
|
||||
expect(params.kind).toBeUndefined();
|
||||
});
|
||||
|
||||
// U1 (#1914 review F1): impact's positional target is now optional, so a uid
|
||||
// alone resolves — parity with `context [name]`.
|
||||
it('resolves uid-only with no positional target (parity with context)', async () => {
|
||||
await impactCommand(undefined, {
|
||||
direction: 'upstream',
|
||||
uid: 'Function:src/auth.ts:login',
|
||||
});
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1);
|
||||
const params = callTool.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(params.target_uid).toBe('Function:src/auth.ts:login');
|
||||
expect(params.target).toBeUndefined();
|
||||
});
|
||||
|
||||
it('errors when neither a target nor a uid is provided', async () => {
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit');
|
||||
}) as never);
|
||||
|
||||
await expect(impactCommand(undefined, {})).rejects.toThrow('process.exit');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(callTool).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects a --prefixed uid value (a flag swallowed by Commander) without forwarding it', async () => {
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit');
|
||||
}) as never);
|
||||
|
||||
await expect(impactCommand(undefined, { uid: '--file' })).rejects.toThrow('process.exit');
|
||||
expect(callTool).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
// U4 (#1914 review F3): an unknown --kind warns to stderr but still resolves.
|
||||
it('warns on an unknown --kind but still forwards the request', async () => {
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
await impactCommand('login', { kind: 'Funktion', direction: 'upstream' });
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1);
|
||||
const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(stderr).toContain('Funktion');
|
||||
expect(stderr).toContain('not a known symbol kind');
|
||||
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not warn for a known --kind', async () => {
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
await impactCommand('login', { kind: 'Function', direction: 'upstream' });
|
||||
|
||||
expect(callTool).toHaveBeenCalledTimes(1);
|
||||
const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(stderr).not.toContain('not a known symbol kind');
|
||||
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -196,13 +196,18 @@ describe('CLI help surface', () => {
|
||||
expect(result.stdout).toContain('--file <path>');
|
||||
});
|
||||
|
||||
it('impact help keeps repo and include-tests flags', () => {
|
||||
it('impact help keeps repo, include-tests, and disambiguation flags', () => {
|
||||
const result = runHelp('impact');
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('--depth <n>');
|
||||
expect(result.stdout).toContain('--include-tests');
|
||||
expect(result.stdout).toContain('--repo <name>');
|
||||
// Disambiguation flags (#1907) — mirror the context help test so a
|
||||
// missing-flag regression on impact is caught here too.
|
||||
expect(result.stdout).toContain('--uid <uid>');
|
||||
expect(result.stdout).toContain('--file <path>');
|
||||
expect(result.stdout).toContain('--kind <kind>');
|
||||
});
|
||||
|
||||
it('detect-changes help exposes compare scope and base-ref flags', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1873,17 +1873,28 @@ interface SearchClient {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores @RequestLine on interfaces without @FeignClient', async () => {
|
||||
it('extracts native @RequestLine on a plain interface without @FeignClient (Feign.builder())', async () => {
|
||||
// The canonical core-Feign usage: a plain interface with `@RequestLine`,
|
||||
// wired up via `Feign.builder()`. There is NO `@FeignClient` annotation
|
||||
// (that is the Spring Cloud variant, which uses Spring MVC annotations and
|
||||
// is mutually exclusive with `@RequestLine`). This is the shape used by
|
||||
// real client-jar consumers, so it must be recognized.
|
||||
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'),
|
||||
path.join(dir, 'src', 'BigModelClient.java'),
|
||||
`
|
||||
import feign.Headers;
|
||||
import feign.RequestLine;
|
||||
import feign.Response;
|
||||
|
||||
interface PlainInterface {
|
||||
@RequestLine("GET /not-a-feign-client")
|
||||
String shouldNotBeExtracted();
|
||||
public interface BigModelClient {
|
||||
@RequestLine("POST /ai/summarization")
|
||||
@Headers("Content-Type: application/json")
|
||||
Response summarize();
|
||||
|
||||
@RequestLine("GET /ai/concurrent")
|
||||
Response concurrent();
|
||||
}
|
||||
`,
|
||||
);
|
||||
@@ -1892,8 +1903,18 @@ interface PlainInterface {
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/not-a-feign-client'),
|
||||
).toBeUndefined();
|
||||
consumers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::POST::/ai/summarization' &&
|
||||
c.meta.framework === 'openfeign' &&
|
||||
c.confidence === 0.75,
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find(
|
||||
(c) => c.contractId === 'http::GET::/ai/concurrent' && c.meta.framework === 'openfeign',
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('mixes @RequestLine and @GetMapping methods on the same @FeignClient interface', async () => {
|
||||
@@ -1993,6 +2014,67 @@ interface WrongKeyClient {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores @RequestLine values that are not a "VERB /path" line', async () => {
|
||||
// `parseRequestLine` only accepts a recognized HTTP verb followed by a
|
||||
// path starting with `/`. Malformed values (no verb, no leading-slash
|
||||
// path, or unknown verb) must be dropped — this guards the relaxed
|
||||
// (no-@FeignClient) matcher from turning arbitrary `@RequestLine` string
|
||||
// literals into bogus contracts.
|
||||
const dir = path.join(tmpDir, 'java-request-line-malformed');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'MalformedClient.java'),
|
||||
`
|
||||
import feign.RequestLine;
|
||||
|
||||
interface MalformedClient {
|
||||
@RequestLine("not a request line at all")
|
||||
String noVerb();
|
||||
|
||||
@RequestLine("GET relative/no/leading/slash")
|
||||
String noLeadingSlash();
|
||||
|
||||
@RequestLine("FETCH /unknown-verb")
|
||||
String unknownVerb();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
// None of the three malformed values should yield a contract.
|
||||
expect(
|
||||
consumers.filter((c) => c.symbolRef.filePath.endsWith('MalformedClient.java')),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores @RequestLine on a class method (Feign proxies are interfaces only)', async () => {
|
||||
// The relaxed matcher still requires an enclosing interface: Feign builds
|
||||
// its proxy from an interface, so a `@RequestLine` on a concrete class
|
||||
// method is not a Feign call and must not be emitted as a consumer.
|
||||
const dir = path.join(tmpDir, 'java-request-line-on-class');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'NotAProxy.java'),
|
||||
`
|
||||
import feign.RequestLine;
|
||||
|
||||
class NotAProxy {
|
||||
@RequestLine("GET /should-not-extract")
|
||||
String call() { return null; }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
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,
|
||||
|
||||
@@ -68,30 +68,9 @@ describe('impact: batching and grouping', () => {
|
||||
const chunkSizes: number[] = [];
|
||||
let chunkCallIndex = 0;
|
||||
|
||||
executeQueryMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
// Depth traversal query (find related nodes) -- return 250 impacted ids
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 250; i++) {
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// NOTE: process-chunk enrichment previously used executeQuery; our
|
||||
// implementation now calls executeParameterized for those chunks. We
|
||||
// still keep this branch to support any legacy calls, but primary
|
||||
// chunk tracking will be handled via executeParameterizedMock below.
|
||||
|
||||
return [];
|
||||
});
|
||||
// BFS frontier query is now parameterized (#1907 U3) — handled in
|
||||
// executeParameterizedMock below; executeQuery is unused by the impact path.
|
||||
executeQueryMock.mockImplementation(async () => []);
|
||||
|
||||
// Handle parameterized calls (including chunked STEP_IN_PROCESS queries)
|
||||
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
|
||||
@@ -117,6 +96,20 @@ describe('impact: batching and grouping', () => {
|
||||
},
|
||||
];
|
||||
}
|
||||
// BFS frontier query (parameterized #1907 U3): return the 250 impacted ids.
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 250; i++) {
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
// Default target resolution
|
||||
return [{ id: 'sym1', name: 'Target', filePath: 'f' }];
|
||||
});
|
||||
@@ -151,6 +144,19 @@ describe('impact: batching and grouping', () => {
|
||||
|
||||
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
// BFS frontier query (parameterized #1907 U3): return 6 impacted nodes.
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 6; i++)
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
if (!query.includes('STEP_IN_PROCESS'))
|
||||
return [{ id: 'symA', name: 'TargetA', filePath: 'f' }];
|
||||
// For STEP_IN_PROCESS in this test, return grouping rows
|
||||
@@ -190,25 +196,9 @@ describe('impact: batching and grouping', () => {
|
||||
];
|
||||
});
|
||||
|
||||
// Prepare impacted nodes: smaller set for clarity (6 nodes -> chunk size default 100 so single chunk)
|
||||
executeQueryMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
// return 6 nodes
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 6; i++)
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
// BFS frontier query is now parameterized (#1907 U3) — handled in
|
||||
// executeParameterizedMock above; executeQuery is unused by the impact path.
|
||||
executeQueryMock.mockImplementation(async () => []);
|
||||
|
||||
const params = { target: 'TargetA', direction: 'downstream', maxDepth: 1 } as any;
|
||||
const res = await (backend as any)._impactImpl(repoHandle, params);
|
||||
@@ -243,23 +233,9 @@ describe('impact: batching and grouping', () => {
|
||||
(backend as any).repos.set(repoHandle.id, repoHandle);
|
||||
(backend as any).ensureInitialized = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// Depth traversal returns 500 impacted nodes
|
||||
executeQueryMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 500; i++)
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
// BFS frontier query is now parameterized (#1907 U3) — handled in
|
||||
// executeParameterizedMock below; executeQuery is unused by the impact path.
|
||||
executeQueryMock.mockImplementation(async () => []);
|
||||
|
||||
const chunkSizes: number[] = [];
|
||||
|
||||
@@ -294,6 +270,19 @@ describe('impact: batching and grouping', () => {
|
||||
return [{ name: 'ModuleA' }];
|
||||
}
|
||||
|
||||
// BFS frontier query (parameterized #1907 U3): return 500 impacted nodes.
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < 500; i++)
|
||||
res.push({
|
||||
id: `node-${i}`,
|
||||
name: `n${i}`,
|
||||
filePath: `file-${i}.js`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
// Default: target resolution
|
||||
return [{ id: 'symX', name: 'TargetX', filePath: 'f' }];
|
||||
});
|
||||
|
||||
@@ -46,30 +46,35 @@ function makeBackend() {
|
||||
return { backend, repoHandle };
|
||||
}
|
||||
|
||||
// The BFS frontier query is now parameterized (bound $frontierIds/$relTypes,
|
||||
// #1907 U3), so the caller rows come back through executeParameterizedMock
|
||||
// (matched on `r.type IN`) rather than executeQueryMock. Symbol resolution and
|
||||
// the label-enrichment UNION still fall through to the default symbol row.
|
||||
function setupMultiDepthHub(d1Count: number, d2Count: number) {
|
||||
let depth = 0;
|
||||
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
if (query.includes('STEP_IN_PROCESS')) return [];
|
||||
if (query.includes('MEMBER_OF')) return [];
|
||||
if (query.includes('r.type IN')) {
|
||||
depth++;
|
||||
const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0;
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
res.push({
|
||||
id: `d${depth}-caller-${i}`,
|
||||
name: `d${depth}caller${i}`,
|
||||
filePath: `src/d${depth}-caller-${i}.ts`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
|
||||
});
|
||||
|
||||
executeQueryMock.mockImplementation(async () => {
|
||||
depth++;
|
||||
const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0;
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
res.push({
|
||||
id: `d${depth}-caller-${i}`,
|
||||
name: `d${depth}caller${i}`,
|
||||
filePath: `src/d${depth}-caller-${i}.ts`,
|
||||
relType: 'CALLS',
|
||||
confidence: null,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
});
|
||||
executeQueryMock.mockImplementation(async () => []);
|
||||
}
|
||||
|
||||
function setupHubSymbol(count: number) {
|
||||
@@ -77,12 +82,7 @@ function setupHubSymbol(count: number) {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
if (query.includes('STEP_IN_PROCESS')) return [];
|
||||
if (query.includes('MEMBER_OF')) return [];
|
||||
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
|
||||
});
|
||||
|
||||
executeQueryMock.mockImplementation(async (...args: any[]) => {
|
||||
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
|
||||
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
|
||||
if (query.includes('r.type IN')) {
|
||||
const res: any[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
res.push({
|
||||
@@ -95,8 +95,10 @@ function setupHubSymbol(count: number) {
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return [];
|
||||
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
|
||||
});
|
||||
|
||||
executeQueryMock.mockImplementation(async () => []);
|
||||
}
|
||||
|
||||
describe('impact: pagination and summaryOnly (#414)', () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Golden capture-parity test for `emitGoScopeCaptures` (issue #1848 follow-up).
|
||||
*
|
||||
* Pins the exact capture output of `emitGoScopeCaptures` across the whole
|
||||
* `test/fixtures/lang-resolution/go-*` corpus plus a synthetic generated-DAO
|
||||
* source, so any future drift in the Go scope-capture path fails CI rather than
|
||||
* only being caught by a coarse perf tripwire or pipeline-level resolver tests.
|
||||
*
|
||||
* This is a FORWARD-DRIFT guard: it locks in the current (post-#1915, verified)
|
||||
* output as the baseline. It does not independently re-prove the original
|
||||
* pre-fix parity — that was established during PR #1915.
|
||||
*
|
||||
* Regenerate the golden intentionally with `UPDATE_GOLDEN=1` in the environment.
|
||||
*
|
||||
* Per fixture the snapshot stores `{ captureGroups, digest }`:
|
||||
* - captureGroups: number of capture matches (makes a count change legible)
|
||||
* - digest: sha256 of a match-grouped, order-sensitive (emission-order)
|
||||
* canonicalization (see canonicalize* below). Order-sensitivity is safe
|
||||
* because emitGoScopeCaptures output is deterministic, and it makes the
|
||||
* digest a true byte-identical guard (a reordering refactor is real drift).
|
||||
* Nothing path/time/id-dependent leaks in.
|
||||
*
|
||||
* Pattern: mirrors test/integration/pipeline-graph-golden.test.ts.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { emitGoScopeCaptures } from '../../../../src/core/ingestion/languages/go/index.js';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
|
||||
// This test lives at test/unit/scope-resolution/go/, so fixtures are THREE
|
||||
// levels up (unlike pipeline-graph-golden.test.ts at test/integration/).
|
||||
const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution');
|
||||
const GOLDEN_DIR = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'go-captures-golden');
|
||||
const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-captures.json');
|
||||
|
||||
const UPDATE = process.env.UPDATE_GOLDEN === '1';
|
||||
|
||||
interface FixtureSnapshot {
|
||||
captureGroups: number;
|
||||
digest: string;
|
||||
}
|
||||
type Snapshot = Record<string, FixtureSnapshot>;
|
||||
|
||||
/**
|
||||
* Canonicalize ONE match. A CaptureMatch is a Record<tag, Capture> (multiple
|
||||
* captures per match), so we group by match to preserve match identity:
|
||||
* build one `tag|text|startLine:startCol-endLine:endCol` string per capture,
|
||||
* sort them within the match, and join. We deliberately do NOT flatten every
|
||||
* capture into one global list — that would lose match boundaries.
|
||||
*/
|
||||
function canonicalizeMatch(match: CaptureMatch): string {
|
||||
const parts: string[] = [];
|
||||
for (const tag of Object.keys(match)) {
|
||||
const cap = match[tag]!;
|
||||
const r = cap.range;
|
||||
parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`);
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/** Order-sensitive (emission-order) digest of a full capture result (match-grouped). */
|
||||
function digestCaptures(matches: readonly CaptureMatch[]): string {
|
||||
// No cross-match sort: the digest reflects emission order so a reordering
|
||||
// refactor surfaces as drift. Within-match key order IS normalized
|
||||
// (canonicalizeMatch sorts), since a CaptureMatch is an unordered Record.
|
||||
const matchStrings = matches.map(canonicalizeMatch);
|
||||
return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex');
|
||||
}
|
||||
|
||||
function snapshotOf(src: string, filePath: string): FixtureSnapshot {
|
||||
const matches = emitGoScopeCaptures(src, filePath);
|
||||
return { captureGroups: matches.length, digest: digestCaptures(matches) };
|
||||
}
|
||||
|
||||
/** All `.go` files under `lang-resolution/go-*`, as sorted repo-relative-ish keys. */
|
||||
function collectGoFixtures(): { key: string; absPath: string }[] {
|
||||
const out: { key: string; absPath: string }[] = [];
|
||||
for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('go-')) continue;
|
||||
const stack = [path.join(FIXTURE_ROOT, entry.name)];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
for (const c of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, c.name);
|
||||
if (c.isDirectory()) stack.push(p);
|
||||
else if (c.name.endsWith('.go')) {
|
||||
out.push({ key: path.relative(FIXTURE_ROOT, p).split(path.sep).join('/'), absPath: p });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.key.localeCompare(b.key));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Small deterministic generated-DAO source — the #1848 shape at correctness scale. */
|
||||
function generateDao(entityCount: number): string {
|
||||
const lines = ['package generated', ''];
|
||||
for (let i = 0; i < entityCount; i++) {
|
||||
const n = String(i).padStart(4, '0');
|
||||
lines.push(
|
||||
`type DefUserDao${n} struct {`,
|
||||
'\tid int64',
|
||||
'\tname string',
|
||||
'}',
|
||||
'',
|
||||
`func (d *DefUserDao${n}) GetID() int64 { return d.id }`,
|
||||
`func (d *DefUserDao${n}) SetName(name string) { d.name = name }`,
|
||||
`func (d *DefUserDao${n}) Validate() error { return nil }`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildSnapshot(): Snapshot {
|
||||
const snap: Snapshot = {};
|
||||
for (const { key, absPath } of collectGoFixtures()) {
|
||||
snap[key] = snapshotOf(fs.readFileSync(absPath, 'utf8'), absPath);
|
||||
}
|
||||
snap['synthetic:dao-20'] = snapshotOf(generateDao(20), 'zz_generated.def_userdao.go');
|
||||
// Stable key order for deterministic JSON serialization.
|
||||
return Object.fromEntries(
|
||||
Object.keys(snap)
|
||||
.sort()
|
||||
.map((k) => [k, snap[k]!]),
|
||||
);
|
||||
}
|
||||
|
||||
function formatGolden(snap: Snapshot): string {
|
||||
return JSON.stringify(snap, null, 2) + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision for what the golden test should do — extracted so the
|
||||
* fail-on-missing-in-CI rule is unit-testable without touching the filesystem
|
||||
* (and can never corrupt the committed golden). A missing golden must NOT
|
||||
* self-heal in CI; locally it regenerates as a first-run convenience.
|
||||
*/
|
||||
type GoldenAction = 'regenerate' | 'compare' | 'fail';
|
||||
function resolveGoldenAction(opts: {
|
||||
update: boolean;
|
||||
exists: boolean;
|
||||
isCI: boolean;
|
||||
}): GoldenAction {
|
||||
if (opts.update) return 'regenerate';
|
||||
if (!opts.exists) return opts.isCI ? 'fail' : 'regenerate';
|
||||
return 'compare';
|
||||
}
|
||||
|
||||
describe('Go scope captures — golden parity', () => {
|
||||
it('matches the committed golden snapshot across all go-* fixtures + DAO shape', () => {
|
||||
const snapshot = buildSnapshot();
|
||||
|
||||
// Read the golden once (no existsSync-then-use, which is a TOCTOU race):
|
||||
// ENOENT means the golden is missing; reuse `existing` for the compare path.
|
||||
let existing: string | undefined;
|
||||
try {
|
||||
existing = fs.readFileSync(GOLDEN_FILE, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
const action = resolveGoldenAction({
|
||||
update: UPDATE,
|
||||
exists: existing !== undefined,
|
||||
isCI: !!process.env.CI, // truthy check: fires on any CI runner, not just CI==='true'
|
||||
});
|
||||
|
||||
if (action === 'fail') {
|
||||
throw new Error(
|
||||
`[go-captures-golden] golden file missing at ${GOLDEN_FILE} in CI. A missing golden must ` +
|
||||
`not self-heal in CI — regenerate it locally with UPDATE_GOLDEN=1 and commit it.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === 'regenerate') {
|
||||
fs.mkdirSync(GOLDEN_DIR, { recursive: true });
|
||||
fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8');
|
||||
console.log(
|
||||
`[go-captures-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden at ${GOLDEN_FILE}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const expected: Snapshot = JSON.parse(existing!);
|
||||
expect(
|
||||
snapshot,
|
||||
'emitGoScopeCaptures output drifted from the committed golden. If this drift is intentional ' +
|
||||
'(or the digest scheme changed), regenerate with ' +
|
||||
'UPDATE_GOLDEN=1 npx vitest run test/unit/scope-resolution/go/go-captures-golden.test.ts',
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
// The fail-on-missing-in-CI rule, asserted purely (no filesystem mutation).
|
||||
it.each([
|
||||
{ update: true, exists: false, isCI: true, expected: 'regenerate' },
|
||||
{ update: false, exists: false, isCI: true, expected: 'fail' },
|
||||
{ update: false, exists: false, isCI: false, expected: 'regenerate' },
|
||||
{ update: false, exists: true, isCI: true, expected: 'compare' },
|
||||
{ update: false, exists: true, isCI: false, expected: 'compare' },
|
||||
])(
|
||||
'resolveGoldenAction($update,$exists,$isCI) -> $expected',
|
||||
({ update, exists, isCI, expected }) => {
|
||||
expect(resolveGoldenAction({ update, exists, isCI })).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('produces a deterministic digest across repeated runs', () => {
|
||||
const src = generateDao(8);
|
||||
expect(digestCaptures(emitGoScopeCaptures(src, 'a.go'))).toBe(
|
||||
digestCaptures(emitGoScopeCaptures(src, 'a.go')),
|
||||
);
|
||||
});
|
||||
|
||||
it('digest is sensitive to capture-match emission order', () => {
|
||||
const matches = emitGoScopeCaptures(generateDao(6), 'a.go');
|
||||
expect(matches.length).toBeGreaterThan(1);
|
||||
const reversed = [...matches].reverse();
|
||||
// Reordering the emission changes the digest — the true byte-identical guard.
|
||||
expect(digestCaptures(reversed)).not.toBe(digestCaptures(matches));
|
||||
});
|
||||
|
||||
it('records a capture-group count for every fixture and the DAO shape', () => {
|
||||
const snapshot = buildSnapshot();
|
||||
const fixtureKeys = collectGoFixtures().map((f) => f.key);
|
||||
// Every collected fixture is present in the snapshot.
|
||||
for (const k of fixtureKeys) expect(snapshot[k]).toBeDefined();
|
||||
// The DAO shape (which has symbols) yields a non-empty capture set.
|
||||
expect(snapshot['synthetic:dao-20']!.captureGroups).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -62,4 +62,104 @@ func main() {
|
||||
expect(tags).toContain('@reference.read');
|
||||
expect(tags).toContain('@reference.write');
|
||||
});
|
||||
|
||||
// ── Edge shapes the #1915 captured-node refactor reasons about but no
|
||||
// lang-resolution fixture exercises (issue #1848 follow-up U2). ──
|
||||
|
||||
it('synthesizes a receiver for a method but not for a func_literal scope', () => {
|
||||
// Source has BOTH a real method and a closure. A weak "no @type-binding.self
|
||||
// anywhere" assertion would pass even if the method_declaration receiver
|
||||
// branch regressed (a closure-only fixture has none to lose); asserting the
|
||||
// method's receiver IS present catches that regression.
|
||||
const src = `
|
||||
package main
|
||||
|
||||
type User struct{ Name string }
|
||||
|
||||
func (u *User) Save() { _ = u.Name }
|
||||
|
||||
func main() {
|
||||
f := func() int { return 1 }
|
||||
_ = f()
|
||||
}
|
||||
`;
|
||||
const matches = emitGoScopeCaptures(src, 'main.go');
|
||||
// The closure is still captured as a @scope.function...
|
||||
expect(matches.some((m) => m['@scope.function']?.text.startsWith('func()'))).toBe(true);
|
||||
// ...and the method's receiver self-binding is synthesized (name + pointer-stripped type)...
|
||||
const selves = matches.filter((m) => m['@type-binding.self'] !== undefined);
|
||||
expect(selves).toHaveLength(1); // exactly one — from the method, not the closure
|
||||
expect(selves[0]!['@type-binding.name']?.text).toBe('u');
|
||||
expect(selves[0]!['@type-binding.type']?.text).toBe('User');
|
||||
});
|
||||
|
||||
it('does not drop a var-form type assertion binding', () => {
|
||||
// `var x int = e.(T)` anchors on a var_declaration, not a short_var_declaration,
|
||||
// so isRawMultiAssignTypeBinding must NOT filter it (old findNodeAtRange path
|
||||
// returned null -> false; the new anchor.type guard reproduces that).
|
||||
const src = `
|
||||
package main
|
||||
|
||||
func main() {
|
||||
var x int = any(1).(int)
|
||||
_ = x
|
||||
}
|
||||
`;
|
||||
const assertion = emitGoScopeCaptures(src, 'main.go').find(
|
||||
(m) => m['@type-binding.assertion'] !== undefined,
|
||||
);
|
||||
expect(assertion).toBeDefined();
|
||||
expect(assertion!['@type-binding.name']?.text).toBe('x');
|
||||
});
|
||||
|
||||
it('does not drop a var-form call-return binding', () => {
|
||||
const src = `
|
||||
package main
|
||||
|
||||
func NewThing() int { return 1 }
|
||||
|
||||
func main() {
|
||||
var y = NewThing()
|
||||
_ = y
|
||||
}
|
||||
`;
|
||||
const callReturn = emitGoScopeCaptures(src, 'main.go').find(
|
||||
(m) => m['@type-binding.call-return'] !== undefined,
|
||||
);
|
||||
expect(callReturn).toBeDefined();
|
||||
expect(callReturn!['@type-binding.name']?.text).toBe('y');
|
||||
});
|
||||
|
||||
it('resolves a single unparenthesized import the same as a grouped one', () => {
|
||||
// Exercises resolveImportNode's no-import_spec_list parent chain. NOTE: not
|
||||
// redundant with go-imports.test.ts, which calls splitGoImportStatement
|
||||
// directly and bypasses captures.ts / resolveImportNode.
|
||||
const single = emitGoScopeCaptures(
|
||||
`
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() { fmt.Println() }
|
||||
`,
|
||||
'main.go',
|
||||
);
|
||||
const sources = single
|
||||
.filter((m) => m['@import.source'] !== undefined)
|
||||
.map((m) => m['@import.source']!.text);
|
||||
expect(sources).toEqual(['fmt']);
|
||||
});
|
||||
|
||||
it('captures a generic function declaration', () => {
|
||||
const src = `
|
||||
package main
|
||||
|
||||
func Map[T any](x T) T { return x }
|
||||
`;
|
||||
const decl = emitGoScopeCaptures(src, 'main.go').find(
|
||||
(m) => m['@declaration.function'] !== undefined,
|
||||
);
|
||||
expect(decl).toBeDefined();
|
||||
expect(decl!['@declaration.name']?.text).toBe('Map');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user