gitnexus 1.6.6-rc.95 → 1.6.6-rc.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -170,6 +170,13 @@ gitnexus clean --all --force # Delete all indexes
170
170
  gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
171
171
  gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
172
172
 
173
+ # Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed
174
+ gitnexus query "<concept>" # Process-grouped hybrid search
175
+ gitnexus context <symbol> [--uid <uid> | --file <path>] # 360° symbol view; flags disambiguate a shared name
176
+ gitnexus impact <symbol> [--uid <uid> | --file <path> | --kind <kind>] # Blast radius; flags disambiguate a shared name
177
+ gitnexus detect-changes # Map the working-tree diff to affected symbols and execution flows
178
+ gitnexus cypher "<query>" # Run a raw Cypher query against the knowledge graph
179
+
173
180
  # Repository groups (multi-repo / monorepo service tracking)
174
181
  gitnexus group create <name> # Create a repository group
175
182
  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`)
@@ -97,6 +97,9 @@ const OPTION_DESCRIPTION_KEYS = {
97
97
  'context|--content': 'help.option.content',
98
98
  'impact|-d, --direction <dir>': 'help.option.impact.direction',
99
99
  'impact|-r, --repo <name>': 'help.option.repo.target',
100
+ 'impact|-u, --uid <uid>': 'help.option.context.uid',
101
+ 'impact|-f, --file <path>': 'help.option.context.file',
102
+ 'impact|--kind <kind>': 'help.option.impact.kind',
100
103
  'impact|--depth <n>': 'help.option.impact.depth',
101
104
  'impact|--include-tests': 'help.option.impact.includeTests',
102
105
  'impact|--limit <n>': 'help.option.impact.limit',
@@ -41,8 +41,9 @@ export declare const en: {
41
41
  readonly 'tool.noIndexed': "GitNexus: No indexed repositories found. Run: gitnexus analyze";
42
42
  readonly 'tool.usage.query': "Usage: gitnexus query <search_query>";
43
43
  readonly 'tool.usage.context': "Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]";
44
- readonly 'tool.usage.impact': "Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]";
44
+ readonly 'tool.usage.impact': "Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]";
45
45
  readonly 'tool.usage.cypher': "Usage: gitnexus cypher <cypher_query>";
46
+ readonly 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.";
46
47
  readonly 'tool.detectChanges.noChanges': "No changes detected.";
47
48
  readonly 'tool.detectChanges.changesSummary': "Changes: {{files}} files, {{symbols}} symbols";
48
49
  readonly 'tool.detectChanges.affectedProcesses': "Affected processes: {{count}}";
@@ -177,6 +178,7 @@ export declare const en: {
177
178
  readonly 'help.option.repo.target': "Target repository";
178
179
  readonly 'help.option.context.uid': "Direct symbol UID (zero-ambiguity lookup)";
179
180
  readonly 'help.option.context.file': "File path to disambiguate common names";
181
+ readonly 'help.option.impact.kind': "Kind filter to disambiguate common names (e.g. Function, Class, Method)";
180
182
  readonly 'help.option.impact.direction': "upstream (dependants) or downstream (dependencies)";
181
183
  readonly 'help.option.impact.depth': "Max relationship depth (default: 3)";
182
184
  readonly 'help.option.impact.includeTests': "Include test files in results";
@@ -41,8 +41,9 @@ export const en = {
41
41
  'tool.noIndexed': 'GitNexus: No indexed repositories found. Run: gitnexus analyze',
42
42
  'tool.usage.query': 'Usage: gitnexus query <search_query>',
43
43
  'tool.usage.context': 'Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]',
44
- 'tool.usage.impact': 'Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]',
44
+ 'tool.usage.impact': 'Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]',
45
45
  'tool.usage.cypher': 'Usage: gitnexus cypher <cypher_query>',
46
+ 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.",
46
47
  'tool.detectChanges.noChanges': 'No changes detected.',
47
48
  'tool.detectChanges.changesSummary': 'Changes: {{files}} files, {{symbols}} symbols',
48
49
  'tool.detectChanges.affectedProcesses': 'Affected processes: {{count}}',
@@ -177,6 +178,7 @@ export const en = {
177
178
  'help.option.repo.target': 'Target repository',
178
179
  'help.option.context.uid': 'Direct symbol UID (zero-ambiguity lookup)',
179
180
  'help.option.context.file': 'File path to disambiguate common names',
181
+ 'help.option.impact.kind': 'Kind filter to disambiguate common names (e.g. Function, Class, Method)',
180
182
  'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)',
181
183
  'help.option.impact.depth': 'Max relationship depth (default: 3)',
182
184
  'help.option.impact.includeTests': 'Include test files in results',
@@ -42,8 +42,9 @@ export declare const cliResources: {
42
42
  readonly 'tool.noIndexed': "GitNexus: No indexed repositories found. Run: gitnexus analyze";
43
43
  readonly 'tool.usage.query': "Usage: gitnexus query <search_query>";
44
44
  readonly 'tool.usage.context': "Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]";
45
- readonly 'tool.usage.impact': "Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]";
45
+ readonly 'tool.usage.impact': "Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]";
46
46
  readonly 'tool.usage.cypher': "Usage: gitnexus cypher <cypher_query>";
47
+ readonly 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.";
47
48
  readonly 'tool.detectChanges.noChanges': "No changes detected.";
48
49
  readonly 'tool.detectChanges.changesSummary': "Changes: {{files}} files, {{symbols}} symbols";
49
50
  readonly 'tool.detectChanges.affectedProcesses': "Affected processes: {{count}}";
@@ -178,6 +179,7 @@ export declare const cliResources: {
178
179
  readonly 'help.option.repo.target': "Target repository";
179
180
  readonly 'help.option.context.uid': "Direct symbol UID (zero-ambiguity lookup)";
180
181
  readonly 'help.option.context.file': "File path to disambiguate common names";
182
+ readonly 'help.option.impact.kind': "Kind filter to disambiguate common names (e.g. Function, Class, Method)";
181
183
  readonly 'help.option.impact.direction': "upstream (dependants) or downstream (dependencies)";
182
184
  readonly 'help.option.impact.depth': "Max relationship depth (default: 3)";
183
185
  readonly 'help.option.impact.includeTests': "Include test files in results";
@@ -253,6 +255,7 @@ export declare const cliResources: {
253
255
  'tool.usage.context': string;
254
256
  'tool.usage.impact': string;
255
257
  'tool.usage.cypher': string;
258
+ 'tool.warn.unknownKind': string;
256
259
  'tool.detectChanges.noChanges': string;
257
260
  'tool.detectChanges.changesSummary': string;
258
261
  'tool.detectChanges.affectedProcesses': string;
@@ -387,6 +390,7 @@ export declare const cliResources: {
387
390
  'help.option.repo.target': string;
388
391
  'help.option.context.uid': string;
389
392
  'help.option.context.file': string;
393
+ 'help.option.impact.kind': string;
390
394
  'help.option.impact.direction': string;
391
395
  'help.option.impact.depth': string;
392
396
  'help.option.impact.includeTests': string;
@@ -43,6 +43,7 @@ export declare const zhCN: {
43
43
  'tool.usage.context': string;
44
44
  'tool.usage.impact': string;
45
45
  'tool.usage.cypher': string;
46
+ 'tool.warn.unknownKind': string;
46
47
  'tool.detectChanges.noChanges': string;
47
48
  'tool.detectChanges.changesSummary': string;
48
49
  'tool.detectChanges.affectedProcesses': string;
@@ -177,6 +178,7 @@ export declare const zhCN: {
177
178
  'help.option.repo.target': string;
178
179
  'help.option.context.uid': string;
179
180
  'help.option.context.file': string;
181
+ 'help.option.impact.kind': string;
180
182
  'help.option.impact.direction': string;
181
183
  'help.option.impact.depth': string;
182
184
  'help.option.impact.includeTests': string;
@@ -41,8 +41,9 @@ export const zhCN = {
41
41
  'tool.noIndexed': 'GitNexus:未找到已索引仓库。请运行:gitnexus analyze',
42
42
  'tool.usage.query': '用法:gitnexus query <搜索词>',
43
43
  'tool.usage.context': '用法:gitnexus context <符号名> [--uid <uid>] [--file <路径>]',
44
- 'tool.usage.impact': '用法:gitnexus impact <符号名> [--direction upstream|downstream]',
44
+ 'tool.usage.impact': '用法:gitnexus impact <符号名> [--uid <uid>] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]',
45
45
  'tool.usage.cypher': '用法:gitnexus cypher <Cypher 查询>',
46
+ 'tool.warn.unknownKind': "--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。",
46
47
  'tool.detectChanges.noChanges': '未检测到变更。',
47
48
  'tool.detectChanges.changesSummary': '变更:{{files}} 个文件,{{symbols}} 个符号',
48
49
  'tool.detectChanges.affectedProcesses': '受影响流程:{{count}}',
@@ -177,6 +178,7 @@ export const zhCN = {
177
178
  'help.option.repo.target': '目标仓库',
178
179
  'help.option.context.uid': '直接符号 UID(零歧义查找)',
179
180
  'help.option.context.file': '用于消除常见名称歧义的文件路径',
181
+ 'help.option.impact.kind': '用于消除常见名称歧义的类型过滤(如 Function、Class、Method)',
180
182
  'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)',
181
183
  'help.option.impact.depth': '最大关系遍历深度(默认:3)',
182
184
  'help.option.impact.includeTests': '在结果中包含测试文件',
package/dist/cli/index.js CHANGED
@@ -142,10 +142,13 @@ program
142
142
  .option('--content', 'Include full symbol source code')
143
143
  .action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand'));
144
144
  program
145
- .command('impact <target>')
145
+ .command('impact [target]')
146
146
  .description('Blast radius analysis: what breaks if you change a symbol')
147
147
  .option('-d, --direction <dir>', 'upstream (dependants) or downstream (dependencies)', 'upstream')
148
148
  .option('-r, --repo <name>', 'Target repository')
149
+ .option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
150
+ .option('-f, --file <path>', 'File path to disambiguate common names')
151
+ .option('--kind <kind>', 'Kind filter to disambiguate common names (e.g. Function, Class, Method)')
149
152
  .option('--depth <n>', 'Max relationship depth (default: 3)')
150
153
  .option('--include-tests', 'Include test files in results')
151
154
  .option('--limit <n>', 'Max symbols per depth level (default: 100)')
@@ -27,9 +27,12 @@ export declare function contextCommand(name: string, options?: {
27
27
  uid?: string;
28
28
  content?: boolean;
29
29
  }): Promise<void>;
30
- export declare function impactCommand(target: string, options?: {
30
+ export declare function impactCommand(target?: string, options?: {
31
31
  direction?: string;
32
32
  repo?: string;
33
+ uid?: string;
34
+ file?: string;
35
+ kind?: string;
33
36
  depth?: string;
34
37
  includeTests?: boolean;
35
38
  limit?: string;
package/dist/cli/tool.js CHANGED
@@ -15,8 +15,8 @@
15
15
  * See the output() function for details (#324).
16
16
  */
17
17
  import { writeSync } from 'node:fs';
18
- import { LocalBackend } from '../mcp/local/local-backend.js';
19
- import { cliErrorKey } from './cli-message.js';
18
+ import { LocalBackend, VALID_NODE_LABELS } from '../mcp/local/local-backend.js';
19
+ import { cliErrorKey, cliWarnKey } from './cli-message.js';
20
20
  import { formatDetectChangesResult } from './detect-changes-format.js';
21
21
  let _backend = null;
22
22
  async function getBackend() {
@@ -72,6 +72,11 @@ export async function queryCommand(queryText, options) {
72
72
  output(result);
73
73
  }
74
74
  export async function contextCommand(name, options) {
75
+ // Reject a `--`-prefixed uid swallowed from a following flag (see impactCommand).
76
+ if (options?.uid?.startsWith('--')) {
77
+ cliErrorKey('tool.usage.context');
78
+ process.exit(1);
79
+ }
75
80
  if (!name?.trim() && !options?.uid) {
76
81
  cliErrorKey('tool.usage.context');
77
82
  process.exit(1);
@@ -87,10 +92,25 @@ export async function contextCommand(name, options) {
87
92
  output(result);
88
93
  }
89
94
  export async function impactCommand(target, options) {
90
- if (!target?.trim()) {
95
+ // A `--`-prefixed uid means Commander swallowed a following flag as the uid
96
+ // value (e.g. `impact --uid --file x` → uid === '--file'). Reject it rather
97
+ // than forwarding a garbage uid that would silently resolve to not-found.
98
+ if (options?.uid?.startsWith('--')) {
91
99
  cliErrorKey('tool.usage.impact');
92
100
  process.exit(1);
93
101
  }
102
+ // Target is an optional positional: a uid alone is enough to resolve (parity
103
+ // with `context [name]`). Only error when neither a target nor a uid is given.
104
+ if (!target?.trim() && !options?.uid) {
105
+ cliErrorKey('tool.usage.impact');
106
+ process.exit(1);
107
+ }
108
+ // Soft-validate --kind: an unknown kind is a no-op hint (the backend scores
109
+ // it but it matches nothing), so warn and proceed rather than rejecting —
110
+ // parity with the lenient MCP surface and forward-compatible with new labels.
111
+ if (options?.kind && !VALID_NODE_LABELS.has(options.kind)) {
112
+ cliWarnKey('tool.warn.unknownKind', { kind: options.kind });
113
+ }
94
114
  try {
95
115
  const backend = await getBackend();
96
116
  const rawLimit = parseInt(options?.limit ?? '', 10);
@@ -98,7 +118,10 @@ export async function impactCommand(target, options) {
98
118
  const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined;
99
119
  const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined;
100
120
  const result = await backend.callTool('impact', {
101
- target,
121
+ target: target || undefined,
122
+ target_uid: options?.uid,
123
+ file_path: options?.file,
124
+ kind: options?.kind,
102
125
  direction: options?.direction || 'upstream',
103
126
  maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
104
127
  includeTests: options?.includeTests ?? false,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Pure predicates gating C# `using` suffix-fallback resolution so BCL usings
3
+ * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
4
+ * file (#1881).
5
+ *
6
+ * Lives in the shared `ingestion/` layer — NOT under `languages/csharp/` — so
7
+ * BOTH the registry-primary scope resolver (`languages/csharp/import-target.ts`)
8
+ * and the legacy DAG resolver (`import-resolvers/csharp.ts`) can import it
9
+ * without an `import-resolvers/ -> languages/` dependency inversion (#5).
10
+ */
11
+ import type { CSharpNamespaceEvidence } from './language-config.js';
12
+ /**
13
+ * Whether the unanchored suffix fallback may run for `targetRaw`.
14
+ *
15
+ * Fails OPEN when the namespace scan was truncated (large repos must not
16
+ * silently lose legitimate edges, #1881 #11) and when no evidence was
17
+ * threaded at all (preserves legacy permissive behavior). The truncation
18
+ * fail-open is carved out for clearly-external roots (BCL / well-known
19
+ * packages) that the repo does not declare, so one incomplete scan can't
20
+ * re-open the #1881 hole repo-wide. Otherwise defers to
21
+ * {@link importAlignsWithDeclaredNamespaces}.
22
+ */
23
+ export declare function csharpSuffixFallbackAllowed(targetRaw: string, evidence: CSharpNamespaceEvidence | undefined): boolean;
24
+ /** True when `targetRaw` plausibly refers to a namespace declared in-repo. */
25
+ export declare function importAlignsWithDeclaredNamespaces(targetRaw: string, declaredNamespaces: ReadonlySet<string> | undefined, rootNamespaces?: ReadonlySet<string>): boolean;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Pure predicates gating C# `using` suffix-fallback resolution so BCL usings
3
+ * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
4
+ * file (#1881).
5
+ *
6
+ * Lives in the shared `ingestion/` layer — NOT under `languages/csharp/` — so
7
+ * BOTH the registry-primary scope resolver (`languages/csharp/import-target.ts`)
8
+ * and the legacy DAG resolver (`import-resolvers/csharp.ts`) can import it
9
+ * without an `import-resolvers/ -> languages/` dependency inversion (#5).
10
+ */
11
+ /**
12
+ * Top-level namespace segments that clearly belong to the BCL / runtime / a
13
+ * ubiquitous third-party package — i.e. roots a normal repo does NOT declare.
14
+ * These stay gated even when the namespace scan is truncated, so a single
15
+ * unreadable file / capped subtree can't silently re-enable BCL→local suffix
16
+ * matches repo-wide (#1881). A repo that legitimately declares one of these
17
+ * roots is still allowed via the alignment escape hatch below.
18
+ */
19
+ const CSHARP_EXTERNAL_ROOTS = new Set([
20
+ // .NET BCL / runtime
21
+ 'System',
22
+ 'Microsoft',
23
+ 'Windows',
24
+ 'Mono',
25
+ // ubiquitous third-party NuGet roots
26
+ 'Newtonsoft',
27
+ 'Serilog',
28
+ 'AutoMapper',
29
+ 'MediatR',
30
+ 'Polly',
31
+ 'FluentValidation',
32
+ 'Grpc',
33
+ 'Google',
34
+ 'Azure',
35
+ 'Amazon',
36
+ 'AWSSDK',
37
+ // common test frameworks
38
+ 'Xunit',
39
+ 'NUnit',
40
+ 'Moq',
41
+ 'FluentAssertions',
42
+ 'NSubstitute',
43
+ 'Shouldly',
44
+ ]);
45
+ /** Whether `targetRaw`'s top-level segment is a clearly-external root. */
46
+ function isExternalRoot(targetRaw) {
47
+ const dot = targetRaw.indexOf('.');
48
+ const top = dot === -1 ? targetRaw : targetRaw.slice(0, dot);
49
+ return CSHARP_EXTERNAL_ROOTS.has(top);
50
+ }
51
+ /**
52
+ * Whether the unanchored suffix fallback may run for `targetRaw`.
53
+ *
54
+ * Fails OPEN when the namespace scan was truncated (large repos must not
55
+ * silently lose legitimate edges, #1881 #11) and when no evidence was
56
+ * threaded at all (preserves legacy permissive behavior). The truncation
57
+ * fail-open is carved out for clearly-external roots (BCL / well-known
58
+ * packages) that the repo does not declare, so one incomplete scan can't
59
+ * re-open the #1881 hole repo-wide. Otherwise defers to
60
+ * {@link importAlignsWithDeclaredNamespaces}.
61
+ */
62
+ export function csharpSuffixFallbackAllowed(targetRaw, evidence) {
63
+ if (evidence === undefined)
64
+ return true;
65
+ if (evidence.truncated) {
66
+ // Keep clearly-external roots blocked through truncation UNLESS the repo
67
+ // actually declares an aligning namespace (the alignment check is the
68
+ // escape hatch — a repo that declares `namespace System;` still resolves).
69
+ if (isExternalRoot(targetRaw) &&
70
+ !importAlignsWithDeclaredNamespaces(targetRaw, evidence.declaredNamespaces, evidence.rootNamespaces)) {
71
+ return false;
72
+ }
73
+ return true;
74
+ }
75
+ return importAlignsWithDeclaredNamespaces(targetRaw, evidence.declaredNamespaces, evidence.rootNamespaces);
76
+ }
77
+ /** True when `targetRaw` plausibly refers to a namespace declared in-repo. */
78
+ export function importAlignsWithDeclaredNamespaces(targetRaw, declaredNamespaces, rootNamespaces) {
79
+ if (declaredNamespaces === undefined || declaredNamespaces.size === 0)
80
+ return false;
81
+ // Exact: the import IS a declared in-repo namespace.
82
+ if (declaredNamespaces.has(targetRaw))
83
+ return true;
84
+ // Child-of: the import's IMMEDIATE parent namespace is declared in-repo.
85
+ // Anchoring on the direct parent — not "any declared prefix" — is what stops
86
+ // a declared BCL prefix from green-lighting an unrelated BCL using: a repo
87
+ // that declares `namespace System;` must NOT make `using
88
+ // System.Threading.Tasks;` resolve to a coincidental local `Tasks.cs`,
89
+ // because the import's parent `System.Threading` is not itself declared
90
+ // (#1881). The case this still allows is a type / `using static` import under
91
+ // a declared namespace laid out without its full path on disk, e.g.
92
+ // `using static MyApp.Utils.Logger;` when `MyApp.Utils` is declared.
93
+ const lastDot = targetRaw.lastIndexOf('.');
94
+ if (lastDot > 0 && declaredNamespaces.has(targetRaw.slice(0, lastDot)))
95
+ return true;
96
+ // Ancestor-of: the import is a strict prefix of some declared namespace
97
+ // (e.g. `using MyApp;` when `MyApp.Models` is declared). Only honored when
98
+ // the import also sits at or above an in-repo root namespace, so a BCL prefix
99
+ // can't qualify merely because a file declares something deeper under it
100
+ // (e.g. `System.Threading.Tasks.Extensions`) (#1881).
101
+ const childPrefix = targetRaw + '.';
102
+ for (const ns of declaredNamespaces) {
103
+ if (ns.startsWith(childPrefix)) {
104
+ return isAtOrAboveInRepoRoot(targetRaw, declaredNamespaces, rootNamespaces);
105
+ }
106
+ }
107
+ return false;
108
+ }
109
+ function isAtOrAboveInRepoRoot(targetRaw, declaredNamespaces, rootNamespaces) {
110
+ const descendantPrefix = targetRaw + '.';
111
+ if (rootNamespaces !== undefined && rootNamespaces.size > 0) {
112
+ for (const root of rootNamespaces) {
113
+ // targetRaw equals a root, or is an ancestor of one (e.g. `using MyApp;`
114
+ // for csproj RootNamespace `MyApp.Core`).
115
+ if (root === targetRaw || root.startsWith(descendantPrefix))
116
+ return true;
117
+ }
118
+ return false;
119
+ }
120
+ // No explicit roots (e.g. no csproj): treat the top-level segment of each
121
+ // declared namespace as the implied root.
122
+ for (const ns of declaredNamespaces) {
123
+ const dot = ns.indexOf('.');
124
+ const top = dot === -1 ? ns : ns.slice(0, dot);
125
+ if (top === targetRaw)
126
+ return true;
127
+ }
128
+ return false;
129
+ }
@@ -5,21 +5,36 @@
5
5
  import { SupportedLanguages } from '../../../../_shared/index.js';
6
6
  import { createStandardStrategy } from '../standard.js';
7
7
  import { resolveCSharpImportInternal, resolveCSharpNamespaceDir } from '../csharp.js';
8
+ import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js';
8
9
  /** C# namespace-based resolution strategy via .csproj configs. */
9
10
  export const csharpNamespaceStrategy = (rawImportPath, _filePath, ctx) => {
10
11
  const csharpConfigs = ctx.configs.csharpConfigs;
11
- if (csharpConfigs.length > 0) {
12
- const resolvedFiles = resolveCSharpImportInternal(rawImportPath, csharpConfigs, ctx.normalizedFileList, ctx.allFileList, ctx.index);
13
- if (resolvedFiles.length > 1) {
14
- const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
15
- if (dirSuffix) {
16
- return { kind: 'package', files: resolvedFiles, dirSuffix };
17
- }
12
+ const evidence = ctx.configs.csharpNamespaces;
13
+ if (csharpConfigs.length === 0) {
14
+ // No csproj there's no namespace→directory mapping to apply, so the
15
+ // generic strategy would normally take over. But that generic suffix match
16
+ // is UNGATED: it re-introduces the BCL→local spurious match the #1881 gate
17
+ // exists to stop. Mirror the registry leg's no-csproj path defer to the
18
+ // generic strategy ONLY for imports that align with an in-repo declared
19
+ // namespace; for everything else (BCL usings) return an authoritative empty
20
+ // result that STOPS the chain (#2 parity). With no evidence threaded the
21
+ // gate fails open, so behavior is unchanged when the scan didn't run.
22
+ if (!csharpSuffixFallbackAllowed(rawImportPath, evidence)) {
23
+ return { kind: 'files', files: [] };
18
24
  }
19
- if (resolvedFiles.length > 0)
20
- return { kind: 'files', files: resolvedFiles };
25
+ return null;
21
26
  }
22
- return null;
27
+ const resolvedFiles = resolveCSharpImportInternal(rawImportPath, csharpConfigs, ctx.normalizedFileList, ctx.allFileList, ctx.index, evidence);
28
+ if (resolvedFiles.length > 1) {
29
+ const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs);
30
+ if (dirSuffix) {
31
+ return { kind: 'package', files: resolvedFiles, dirSuffix };
32
+ }
33
+ }
34
+ // Authoritative once csproj configs exist: return even an empty result to
35
+ // STOP the chain, so the generic suffix fallback can't re-introduce the
36
+ // gated BCL→local match this resolver just suppressed (#1881).
37
+ return { kind: 'files', files: resolvedFiles };
23
38
  };
24
39
  export const csharpImportConfig = {
25
40
  language: SupportedLanguages.CSharp,
@@ -5,12 +5,16 @@
5
5
  * This file contains shared helpers for namespace-based resolution.
6
6
  */
7
7
  import type { SuffixIndex } from './utils.js';
8
- import type { CSharpProjectConfig } from '../language-config.js';
8
+ import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../language-config.js';
9
9
  /**
10
10
  * Resolve a C# using-directive import path to matching .cs files (low-level helper).
11
11
  * Tries single-file match first, then directory match for namespace imports.
12
+ *
13
+ * The final unanchored suffix fallback is gated on `evidence` so BCL usings
14
+ * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
15
+ * file (#1881). When `evidence` is omitted the fallback stays permissive.
12
16
  */
13
- export declare function resolveCSharpImportInternal(importPath: string, csharpConfigs: CSharpProjectConfig[], normalizedFileList: string[], allFileList: string[], index?: SuffixIndex): string[];
17
+ export declare function resolveCSharpImportInternal(importPath: string, csharpConfigs: CSharpProjectConfig[], normalizedFileList: string[], allFileList: string[], index?: SuffixIndex, evidence?: CSharpNamespaceEvidence): string[];
14
18
  /**
15
19
  * Compute the directory suffix for a C# namespace import (for PackageMap).
16
20
  * Returns a suffix like "/ProjectDir/Models/" or null if no config matches.
@@ -5,11 +5,16 @@
5
5
  * This file contains shared helpers for namespace-based resolution.
6
6
  */
7
7
  import { suffixResolve } from './utils.js';
8
+ import { csharpSuffixFallbackAllowed } from '../csharp-namespace-gate.js';
8
9
  /**
9
10
  * Resolve a C# using-directive import path to matching .cs files (low-level helper).
10
11
  * Tries single-file match first, then directory match for namespace imports.
12
+ *
13
+ * The final unanchored suffix fallback is gated on `evidence` so BCL usings
14
+ * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local
15
+ * file (#1881). When `evidence` is omitted the fallback stays permissive.
11
16
  */
12
- export function resolveCSharpImportInternal(importPath, csharpConfigs, normalizedFileList, allFileList, index) {
17
+ export function resolveCSharpImportInternal(importPath, csharpConfigs, normalizedFileList, allFileList, index, evidence) {
13
18
  const namespacePath = importPath.replace(/\./g, '/');
14
19
  const results = [];
15
20
  for (const config of csharpConfigs) {
@@ -79,7 +84,11 @@ export function resolveCSharpImportInternal(importPath, csharpConfigs, normalize
79
84
  return results;
80
85
  }
81
86
  }
82
- // Fallback: suffix matching without namespace stripping (single file)
87
+ // Fallback: suffix matching without namespace stripping (single file).
88
+ // Gated on in-repo declared-namespace evidence (#1881).
89
+ if (!csharpSuffixFallbackAllowed(importPath, evidence)) {
90
+ return [];
91
+ }
83
92
  const pathParts = namespacePath.split('/').filter(Boolean);
84
93
  const fallback = suffixResolve(pathParts, normalizedFileList, allFileList, index);
85
94
  return fallback ? [fallback] : [];
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Extracted from import-resolution.ts to co-locate types with their consumers.
5
5
  */
6
- import type { TsconfigPaths, GoModuleConfig, CSharpProjectConfig, ComposerConfig } from '../language-config.js';
6
+ import type { TsconfigPaths, GoModuleConfig, CSharpProjectConfig, CSharpNamespaceEvidence, ComposerConfig } from '../language-config.js';
7
7
  import type { SwiftPackageConfig } from '../language-config.js';
8
8
  import type { SuffixIndex } from './utils.js';
9
9
  import type { SupportedLanguages } from '../../../_shared/index.js';
@@ -28,6 +28,8 @@ export interface ImportConfigs {
28
28
  composerConfig: ComposerConfig | null;
29
29
  swiftPackageConfig: SwiftPackageConfig | null;
30
30
  csharpConfigs: CSharpProjectConfig[];
31
+ /** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */
32
+ csharpNamespaces?: CSharpNamespaceEvidence;
31
33
  }
32
34
  /** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */
33
35
  export interface ImportResolutionContext {
@@ -26,6 +26,35 @@ export interface CSharpProjectConfig {
26
26
  /** Directory containing the .csproj file */
27
27
  projectDir: string;
28
28
  }
29
+ /**
30
+ * Declared-namespace evidence used to gate C# suffix-fallback resolution so
31
+ * BCL usings (e.g. `System.Threading.Tasks`) can't match a coincidentally-
32
+ * named local file (#1881).
33
+ */
34
+ export interface CSharpNamespaceEvidence {
35
+ /** Every `namespace X.Y` declared in-repo (scan may be capped — see `truncated`). */
36
+ readonly declaredNamespaces?: ReadonlySet<string>;
37
+ /** csproj RootNamespace values plus the top-level segment of each declared
38
+ * namespace — the anchor set for the parent-namespace gate direction. */
39
+ readonly rootNamespaces?: ReadonlySet<string>;
40
+ /** True when the BFS hit its dir/depth cap, so the namespace set may be
41
+ * incomplete; the gate fails open (allows) in that case. */
42
+ readonly truncated?: boolean;
43
+ }
44
+ /** Result of a single BFS over a repo collecting both csproj configs and
45
+ * declared `.cs` namespaces (one disk traversal — see `scanCSharpProject`). */
46
+ export interface CSharpProjectScan {
47
+ readonly configs: CSharpProjectConfig[];
48
+ readonly declaredNamespaces: ReadonlySet<string>;
49
+ readonly rootNamespaces: ReadonlySet<string>;
50
+ readonly truncated: boolean;
51
+ }
52
+ /** Project the one-pass {@link CSharpProjectScan} into the
53
+ * {@link CSharpNamespaceEvidence} both import-resolution legs thread to the
54
+ * #1881 gate — one shape, two carriers (`ImportConfigs.csharpNamespaces` for
55
+ * the legacy DAG, `CsharpResolutionConfig.namespaces` for the scope resolver).
56
+ * Keeps the field mapping in one place so the two carriers can't drift. */
57
+ export declare function csharpScanToEvidence(scan: CSharpProjectScan): CSharpNamespaceEvidence;
29
58
  /** Swift Package Manager module config */
30
59
  export interface SwiftPackageConfig {
31
60
  /** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
@@ -43,10 +72,18 @@ export declare function loadGoModulePath(repoRoot: string): Promise<GoModuleConf
43
72
  /** Parse composer.json to extract PSR-4 autoload mappings (including autoload-dev). */
44
73
  export declare function loadComposerConfig(repoRoot: string): Promise<ComposerConfig | null>;
45
74
  /**
46
- * Parse .csproj files to extract RootNamespace.
47
- * Scans the repo root for .csproj files and returns configs for each.
75
+ * Single BFS over a repo that collects BOTH .csproj configs and the set of
76
+ * `namespace` declarations from `.cs` files.
77
+ *
78
+ * The csproj walk is cheap (a handful of project files); the namespace scan
79
+ * is NOT — it opens and reads every `.cs` file in the repo to collect its
80
+ * `namespace` declarations. That `.cs` read cost is the price of the #1881
81
+ * gate, not a saving: collapsing the csproj and namespace walks into one BFS
82
+ * avoids a second directory traversal, but the per-file `.cs` reads are new
83
+ * work this scan introduces. Reads within a directory are issued in bounded
84
+ * windows (see below); directories are still visited breadth-first.
48
85
  */
49
- export declare function loadCSharpProjectConfig(repoRoot: string): Promise<CSharpProjectConfig[]>;
86
+ export declare function scanCSharpProject(repoRoot: string): Promise<CSharpProjectScan>;
50
87
  export declare function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null>;
51
88
  /** Load all language-specific configs once for an ingestion run. */
52
89
  export declare function loadImportConfigs(repoRoot: string): Promise<ImportConfigs>;