diff --git a/.claude/hooks/bash-guard.py b/.claude/hooks/bash-guard.py deleted file mode 100755 index 5ec53db827..0000000000 --- a/.claude/hooks/bash-guard.py +++ /dev/null @@ -1,303 +0,0 @@ -#!/usr/bin/env python3 -""" -Claude Code PreToolUse Bash guard for Widthdom/CodeIndex. - -Purpose: -- force CodeIndex dogfooding through the locally built cdidx.dll -- block shell grep/file-discovery escape hatches -- block destructive/exfiltrating commands -- inspect script files before execution so scripts cannot hide grep/dangerous calls -- scan staged changes before git commit to reduce API key / secret accidents -""" - -from __future__ import annotations - -import json -import os -import re -import shlex -import shutil -import subprocess -import sys -from pathlib import Path - - -MAX_SCRIPT_SCAN_BYTES = 512 * 1024 -LOCAL_CDIDX_REL = Path("src/CodeIndex/bin/Debug/net8.0/cdidx.dll") - -SHELL_CONTROL_RE = re.compile(r"(?s)(?:&&|\|\||;|\|&|\||&|`|\$\(|<|>|\n)") - -SEARCH_OR_DISCOVERY = re.compile( - r"""(?ix) - (^|[\s;&|()`]) - ( - grep|egrep|fgrep|zgrep|rgrep| - rg|ripgrep|ag|ack|ack-grep| - find|fd|fdfind|locate|mlocate|mdfind - ) - (?=\s|$) - """ -) - -GLOBAL_CDIDX = re.compile(r"(?i)(^|[\s;&|()`/])cdidx(?!\.dll)(?=\s|$)") -GIT_GREP = re.compile(r"(?i)(^|[\s;&|()`])git\s+grep\b") - -DANGEROUS_PATTERNS: list[tuple[re.Pattern[str], str]] = [ - (SEARCH_OR_DISCOVERY, "shell search/file-discovery command is blocked; use the local cdidx.dll"), - (GLOBAL_CDIDX, "global cdidx is blocked; use dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll"), - (GIT_GREP, "git grep is blocked; use the local cdidx.dll"), - - (re.compile(r"(?i)\brm\s+-[^\n;|&]*r[^\n;|&]*f\b|\brm\s+-[^\n;|&]*f[^\n;|&]*r\b"), "recursive forced rm is blocked"), - (re.compile(r"(?i)\brm\s+-r\b"), "recursive rm is blocked"), - (re.compile(r"(?i)\b(?:rmdir|unlink|shred|srm|truncate)\b"), "destructive filesystem command is blocked"), - (re.compile(r"(?i)\bdd\s+(?:if|of)="), "dd raw device/file copy is blocked"), - (re.compile(r"(?i)\b(?:mkfs|newfs)\b"), "filesystem formatting is blocked"), - (re.compile(r"(?i)\bdiskutil\s+(?:erase|partition|apfs\s+delete)\b"), "disk erase/partition operation is blocked"), - (re.compile(r"(?i)\bchmod\s+(?:777|-R)\b|\bchown\s+-R\b|\bchgrp\s+-R\b"), "dangerous recursive permission/owner change is blocked"), - (re.compile(r"(?i)\b(?:sudo|su|doas)\b"), "privilege escalation is blocked"), - (re.compile(r"(?i)\b(?:killall|pkill)\b|\bkill\s+-9\b"), "broad process killing is blocked"), - - (re.compile(r"(?i)\b(?:curl|wget|http|https|xh|aria2c)\b"), "network download/exfil command is blocked"), - (re.compile(r"(?i)\b(?:curl|wget)\b.*\|\s*(?:sh|bash|zsh|python|ruby|perl)\b"), "download-and-execute is blocked"), - (re.compile(r"(?i)\b(?:ssh|scp|sftp|rsync|rclone|nc|ncat|netcat|socat|telnet|ftp)\b"), "remote shell/file transfer is blocked"), - (re.compile(r"(?i)\b(?:pbcopy|pbpaste)\b"), "clipboard access is blocked"), - - (re.compile(r"(?i)\b(?:open|osascript|automator)\b|\bshortcuts\s+run\b"), "macOS automation/app launching is blocked"), - (re.compile(r"(?i)\b(?:launchctl|security|tccutil|spctl|csrutil|tmutil)\b"), "macOS security/system command is blocked"), - (re.compile(r"(?i)\bdefaults\s+write\b|\bplutil\s+-replace\b"), "macOS preference modification is blocked"), - - (re.compile(r"(?i)\bgit\s+push\b"), "git push is blocked"), - (re.compile(r"(?i)\bgit\s+tag\b"), "git tag is blocked unless explicitly performed by the user"), - (re.compile(r"(?i)\bgit\s+reset\s+--hard\b"), "git reset --hard is blocked"), - (re.compile(r"(?i)\bgit\s+(?:checkout|restore)\s+\.\b"), "checkout/restore of entire worktree is blocked"), - (re.compile(r"(?i)\bgit\s+clean\s+-[^\n;|&]*f\b"), "git clean -f is blocked"), - (re.compile(r"(?i)\bgit\s+add\s+(?:\.|-A|--all)\b"), "bulk git add is blocked; add explicit safe files only"), - (re.compile(r"(?i)\bgit\s+commit\s+--amend\b|\bgit\s+rebase\b|\bgit\s+filter-branch\b|\bgit\s+update-ref\b"), "history rewriting is blocked"), - - (re.compile(r"(?i)\b(?:npm|yarn|pnpm)\s+publish\b|\bdotnet\s+nuget\s+push\b|\bnuget\s+push\b"), "package publishing is blocked"), - (re.compile(r"(?i)\b(?:npm\s+login|npm\s+adduser)\b"), "package registry login is blocked"), - (re.compile(r"(?i)\b(?:npx|npm\s+exec|yarn\s+dlx|pnpm\s+dlx)\b"), "ephemeral package execution is blocked"), - (re.compile(r"(?i)\b(?:terraform\s+(?:apply|destroy)|kubectl\s+(?:apply|delete)|helm\s+(?:install|upgrade|uninstall))\b"), "infra mutation is blocked"), - (re.compile(r"(?i)\bdocker\s+(?:push|login|system\s+prune|volume\s+rm|rm|rmi)\b|\bdocker\s+buildx\s+build\b.*--push\b"), "dangerous docker operation is blocked"), - (re.compile(r"(?i)\b(?:aws|gcloud|az)\b"), "cloud CLI is blocked"), - (re.compile(r"(?i)\bgh\s+(?:auth|api|secret|release|repo\s+create|repo\s+fork|pr\s+merge)\b"), "GitHub CLI high-risk operation is blocked"), - - (re.compile(r"(?i)\b(?:cat|less|more|head|tail|sed|awk|python|python3|node|ruby|perl|sqlite3)\b.*(?:\.env\b|\.env\.|\.pem\b|\.key\b|id_rsa|id_ed25519|credentials?|secrets?)"), "reading secret-looking files is blocked"), - (re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=:-]{20,}"), "inline secret-looking value in command is blocked"), -] - -SCRIPT_EXTENSIONS = { - ".sh", ".bash", ".zsh", ".fish", - ".py", ".rb", ".pl", ".js", ".mjs", ".cjs", ".ts", ".php" -} - -INTERPRETERS = { - "bash", "sh", "zsh", "fish", - "python", "python3", "ruby", "perl", "node", "deno", "php" -} - -SCRIPT_FORBIDDEN_PATTERNS: list[tuple[re.Pattern[str], str]] = [ - (SEARCH_OR_DISCOVERY, "script contains shell search/file-discovery command"), - (GLOBAL_CDIDX, "script contains global cdidx call"), - (GIT_GREP, "script contains git grep"), - *DANGEROUS_PATTERNS, -] - -SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [ - (re.compile(r"AKIA[0-9A-Z]{16}"), "AWS access key"), - (re.compile(r"ASIA[0-9A-Z]{16}"), "AWS temporary access key"), - (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "private key"), - (re.compile(r"ghp_[A-Za-z0-9_]{30,}"), "GitHub classic token"), - (re.compile(r"github_pat_[A-Za-z0-9_]{60,}"), "GitHub fine-grained token"), - (re.compile(r"sk-[A-Za-z0-9_-]{20,}"), "OpenAI-style API key"), - (re.compile(r"xox[baprs]-[A-Za-z0-9-]{20,}"), "Slack token"), - (re.compile(r"AIza[0-9A-Za-z_-]{35}"), "Google API key"), - (re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=:-]{20,}"), "generic secret assignment"), -] - - -def emit_deny(reason: str) -> None: - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": reason, - } - }, ensure_ascii=False)) - sys.exit(0) - - -def load_payload() -> dict: - try: - return json.load(sys.stdin) - except Exception as exc: - emit_deny(f"failed to parse Claude Code hook input; failing closed: {exc}") - - -def get_command(payload: dict) -> str: - tool_input = payload.get("tool_input") or {} - command = tool_input.get("command") - if not isinstance(command, str): - emit_deny("Bash command missing from hook input; failing closed") - return command - - -def is_relative_to(path: Path, parent: Path) -> bool: - try: - path.relative_to(parent) - return True - except ValueError: - return False - - -def is_safe_local_cdidx_command(command: str, project_root: Path) -> bool: - """Allow only the locally built CodeIndex DLL, with no shell control operators.""" - if SHELL_CONTROL_RE.search(command): - return False - try: - tokens = shlex.split(command, posix=True) - except ValueError: - return False - if len(tokens) < 2: - return False - if tokens[0] != "dotnet": - return False - - dll = Path(tokens[1]) - if not dll.is_absolute(): - dll = (project_root / dll).resolve() - expected = (project_root / LOCAL_CDIDX_REL).resolve() - return dll == expected - - -def resolve_candidate(token: str, cwd: Path) -> Path | None: - if not token or token.startswith("-"): - return None - if token in {"-c", "-e", "--eval", "-"}: - return None - p = Path(token) - if not p.is_absolute(): - p = cwd / p - try: - return p.resolve() - except Exception: - return None - - -def candidate_script_paths(command: str, cwd: Path) -> list[Path]: - try: - tokens = shlex.split(command, posix=True) - except ValueError: - return [] - - if not tokens: - return [] - - result: list[Path] = [] - first = Path(tokens[0]).name - - if first in INTERPRETERS: - if any(t in {"-c", "-e", "--eval"} for t in tokens[1:]): - emit_deny("inline interpreter execution is blocked; use a reviewed script file instead") - for token in tokens[1:]: - if token.startswith("-"): - continue - path = resolve_candidate(token, cwd) - if path is not None: - result.append(path) - break - return result - - path = resolve_candidate(tokens[0], cwd) - if path and (tokens[0].startswith("./") or path.suffix in SCRIPT_EXTENSIONS): - result.append(path) - - return result - - -def check_raw_command(command: str, project_root: Path) -> None: - if is_safe_local_cdidx_command(command, project_root): - return - - for pattern, reason in DANGEROUS_PATTERNS: - if pattern.search(command): - emit_deny(reason) - - -def check_script(path: Path, project_root: Path) -> None: - if not path.exists(): - return - if not path.is_file(): - return - - if not is_relative_to(path, project_root): - emit_deny(f"script outside project is blocked: {path}") - - try: - data = path.read_bytes()[:MAX_SCRIPT_SCAN_BYTES] - except Exception as exc: - emit_deny(f"could not inspect script before execution; failing closed: {path}: {exc}") - - text = data.decode("utf-8", errors="ignore") - for pattern, reason in SCRIPT_FORBIDDEN_PATTERNS: - if pattern.search(text): - emit_deny(f"{reason}: {path}") - - -def staged_secret_check(cwd: Path) -> None: - gitleaks = shutil.which("gitleaks") - if gitleaks: - proc = subprocess.run( - [gitleaks, "protect", "--staged", "--redact", "--verbose"], - cwd=str(cwd), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - timeout=60, - ) - if proc.returncode != 0: - output = (proc.stdout or "").strip() - if len(output) > 2000: - output = output[:2000] + "\n..." - emit_deny("gitleaks blocked this commit:\n" + output) - return - - proc = subprocess.run( - ["git", "diff", "--cached", "--unified=0", "--no-ext-diff"], - cwd=str(cwd), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=30, - ) - if proc.returncode != 0: - emit_deny("could not inspect staged diff for secrets; install gitleaks or fix git diff") - - added_lines = "\n".join( - line[1:] for line in proc.stdout.splitlines() - if line.startswith("+") and not line.startswith("+++") - ) - for pattern, name in SECRET_PATTERNS: - if pattern.search(added_lines): - emit_deny(f"secret-looking staged content detected before commit: {name}; install gitleaks for better scanning") - - -def main() -> None: - payload = load_payload() - command = get_command(payload) - cwd = Path(payload.get("cwd") or os.getcwd()).resolve() - project_root = Path(os.environ.get("CLAUDE_PROJECT_DIR") or cwd).resolve() - - check_raw_command(command, project_root) - - for script in candidate_script_paths(command, cwd): - check_script(script, project_root) - - if re.search(r"(?i)(^|[\s;&|()`])git\s+commit\b", command): - staged_secret_check(cwd) - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 0f37bf7c9c..0000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -repos: - - repo: https://github.com/gitleaks/gitleaks - rev: v8.30.1 - hooks: - - id: gitleaks diff --git a/CHANGELOG.md b/CHANGELOG.md index d741916453..2b692e3922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Developer Guide now distinguishes SQLite's role from "zero dependencies" and labels release numbers as examples** — Clarified that cdidx remains a zero-configuration, single-file CLI with exactly one production dependency (`Microsoft.Data.Sqlite`), and made the release-workflow checklist explicitly state that `1.9.0` is only an example version to substitute during an actual release. Affected: `DEVELOPER_GUIDE.md`. #### Fixed +- **`deps` C# attribute suffix alias now only matches class-like targets, and `impact` metadata bypass is suppressed when the class-like target name is ambiguous across namespaces (#293 follow-up)** — The earlier `#293` follow-up made `deps` UNION a C#-only `symbol_name || 'Attribute'` suffix alias into the logical-reference CTE so `[Foo]` attribute consumers could reach `FooAttribute` class files, and made `impact` (`GetFileDependencyHintsToResolvedType`) bypass the structured-evidence guard whenever any contributing reference was `attribute` / `annotation`. Both changes were too broad. On `deps` the alias joined purely by name, so `[Foo]` would also depend on any file that merely defined a function / property / variable named `FooAttribute` (e.g. `void MyAuditAttribute()` in an unrelated helper). On `impact` the metadata bypass did not check whether the target name was uniquely a class-like definition, so when the same unqualified name (e.g. `MyAuditAttribute`) existed in multiple namespaces the rename/remove blast radius was over-reported — attribute / annotation reference rows only keep the short name and cannot be disambiguated. `DbReader.GetFileDependencies` now tags synthetic alias rows with `is_attribute_alias = 1` (real rows are `0`), propagates the flag through `source_name_counts` via `MIN`, and restricts alias edges to class-like target kinds (`class` / `struct` / `interface`) in the `edges` CTE join. The `target_files` CTE is also switched from `DISTINCT` over `(path, lang, name, kind)` to `GROUP BY (path, lang, name)` with a `MAX(...)` `has_class_like_kind` flag, so files that define both a class and a same-named function (e.g. the C# constructor `Target()` inside `class Target`) no longer fan out into two target rows and inflate the `deps` reference count. `DbReader.GetFileDependencyHintsToResolvedType` now calls a new `IsMetadataTargetUnambiguous(definition, lang, pathPatterns, excludePathPatterns, excludeTests)` helper that counts class-like definitions with the same unqualified name *within the active impact scope* (graph-supported languages, plus the caller's `--lang` / `--path` / `--exclude-path` / `--exclude-tests` filters); the metadata evidence-guard bypass is only applied when that in-scope count is ≤ 1. If the name is ambiguous within scope, metadata-only edges fall through to the normal `SourceFileHasStructuredTypeEvidence` check, so pure `[MyAudit]` consumers are dropped instead of being mis-attributed. Scoping matters: a same-named class in an unrelated language / path / test tree must not suppress the C# metadata bypass, because attribute reference rows are already language-qualified through the graph-supported reference join, and the README contract for `impact` explicitly promises "active `--lang` / `--path` / `--exclude-path` / `--exclude-tests` scope". The C# attribute deps test `GetFileDependencies_MatchesCSharpAttributeSuffixConvention`, the unambiguous single-definition `impact` test, and the pre-existing graph-queries dedupe test all still pass. Added `DbReaderTests.GetFileDependencies_CSharpAttributeAliasOnlyMatchesClassLikeTargets` (ambiguous same-name function must not produce a spurious edge), `DbReaderTests.GetFileDependencyHints_SuppressesCSharpAttributeMetadataBypassOnAmbiguousTarget` (two `MyAuditAttribute` classes within the same impact scope suppress the metadata heuristic hint), `DbReaderTests.GetFileDependencyHints_CSharpAttributeMetadataBypassAppliesWhenTargetUnambiguous` (single class definition still surfaces the `[MyAudit]` consumer), `DbReaderTests.GetFileDependencyHints_MetadataBypassAmbiguityGuard_RespectsLangScope` (same-named Java annotation must not suppress a `--lang csharp` query), `DbReaderTests.GetFileDependencyHints_MetadataBypassAmbiguityGuard_RespectsPathScope` (out-of-scope `src/B/` same-named class must not suppress a `src/A/`-scoped query), and `DbReaderTests.GetFileDependencyHints_MetadataBypassAmbiguityGuard_RespectsExcludeTests` (test-tree same-named class must not suppress an `--exclude-tests` query). `ResolveImpactFallbackNames` is additionally tightened so the C# `Attribute`-suffix alias is only applied to the resolved definition's own name: previously the alias was applied to every same-file fallback name, so a nested `BarAttribute` inside the file that defines `FooAttribute` caused `impact FooAttribute` to falsely attribute `[Bar]` use sites to `FooAttribute`'s blast radius. Added `DbReaderTests.GetFileDependencyHints_CSharpAttributeSuffixAlias_DoesNotLeakToSameFileSiblings` to pin this. `GetFileDependencies` is additionally restructured so metadata rows (`attribute` / `annotation`) cannot leak onto bare-name call-graph targets: the `logical_references` CTE now carries an `is_metadata` flag, `source_name_counts` groups by `(source_file_id, source_path, source_lang, symbol_name, is_attribute_alias, is_metadata)` so metadata and non-metadata rows with the same symbol_name stay separate, the `edges` CTE restricts ALL metadata edges (not just synthetic aliases) to `tf.has_class_like_kind = 1`, and two new CTEs resolve the remaining false-positive shapes: `metadata_raw_suppression` drops the raw C# `[Foo]` row when the synthetic `FooAttribute` alias already resolves to a class-like target in the same source file (so `[Foo]` does not fan out to both `class FooAttribute` and a plain `class Foo` that shares the bare name), and `target_ambiguity` drops metadata edges when the same-named symbol resolves to multiple class-like definitions in scope (so ambiguous `[MyAudit]` usage is left to `impact` / `references` to disambiguate rather than being fanned out to every same-named attribute class). The existing `GetFileDependencies_IncludesMetadataReferencesAsCompileTimeDependencies` test (which pins `[JsonConverter(...)]` → `class JsonConverter : Attribute` for a target class whose name does NOT end in `Attribute`) still passes because only one class-like target exists in scope, so `target_ambiguity` keeps it, the `is_metadata` / class-like restriction accepts it, and `metadata_raw_suppression` finds no synthetic `JsonConverterAttribute` alias target and therefore does not suppress the raw row. Added `DbReaderTests.GetFileDependencies_CSharpAttributeRawDoesNotLeakToBareNameClass_WhenSuffixTargetExists` (raw `[MyAudit]` row is suppressed when the alias resolves to `class MyAuditAttribute`, so `[MyAudit]` does NOT depend on an unrelated plain `class MyAudit` file), `DbReaderTests.GetFileDependencies_CSharpAttributeDoesNotLeakToSameNameMethodOrProperty` (fully-qualified `[MyAuditAttribute]` metadata does NOT depend on a file that only defines a method / property named `MyAuditAttribute`), and `DbReaderTests.GetFileDependencies_CSharpAttributeDoesNotFanOutWhenMultipleSameNameAttributeClasses` (two same-named attribute classes in different namespaces cause the metadata edge to be dropped as ambiguous). Both the `impact` ambiguity guard (`IsMetadataTargetUnambiguous`) and the `deps` ambiguity CTE (`target_ambiguity`) are additionally tightened to count class-like definitions at symbol-identity level instead of path level: `IsMetadataTargetUnambiguous` now uses `SELECT DISTINCT f.path, s.line, s.name` so two same-named class-like definitions in one source file (e.g. idiomatic C# `namespace A { class FooAttribute { } } namespace B { class FooAttribute { } }` in a single .cs file) register as ambiguous; `target_ambiguity` joins `target_files` back through `files` + `symbols` with `COUNT(*)` over class-like symbol rows so the same same-file duplicate case correctly drops the `[Foo]` metadata edge. Path-level counting previously treated these same-file duplicates as count=1 because `SELECT DISTINCT f.path` and `target_files` (grouped by `dst.path`) both collapse the two definitions into a single row, so `cdidx deps` and `cdidx impact` were confidently reporting an ambiguous short-name metadata reference as a definite dependency / blast-radius edge — contradicting the design intent that "ambiguity" includes duplicates in one file. Added `DbReaderTests.GetFileDependencies_CSharpAttributeAmbiguityCountsSameFileDuplicateClassDefinitions` and `DbReaderTests.GetFileDependencyHints_MetadataBypassAmbiguityGuard_CountsSameFileDuplicateDefinitions` to pin the symbol-identity-level counting for both `deps` and `impact`. `IsMetadataTargetUnambiguous` is additionally tightened to wrap `--path` / `--exclude-path` parameters through `EscapeLikeQuery` and bind them as `%...%` patterns, matching the LIKE semantics used by the rest of the reader (search / references / deps etc.); without the wrap the in-scope class-like count would underflow to 1 even when two same-named attribute classes sit side-by-side in the requested CLI subtree such as `--path src/A/`, and the metadata bypass would falsely fire. Added `DbReaderTests.GetFileDependencyHints_MetadataBypassAmbiguityGuard_CliPathPatternEscaping_SuppressesWhenInScopeIsAmbiguous` to pin this. `CSharpNoArgAttributeRegex` is additionally widened to accept an optional generic-argument list after the attribute name (`(?:\s*<[^\n]+?>)?`) so generic no-arg attribute forms such as `[MyAudit]`, `[assembly: MyAttr]`, the multi-line `[\n MyAttr\n]`, and nested-generic forms like `[MyAttr>]` / `[MyOther>>]` are indexed as `attribute` references instead of falling through both `CallRegex` (requires `(`) and the old no-arg regex (which ended at `]` / `,` / EOL and therefore rejected `` between the name and `]`). The inner generic segment now uses a non-greedy `[^\n]+?` so nested `>` tokens (e.g. the closing `>` of an inner `Dictionary`) no longer terminate the match prematurely — the old `[^>\n]+` excluded `>` and therefore broke on the first inner `>`, dropping the reference row for nested-generic attribute usages. Without the widening those sites produced zero reference rows, so `deps` / `impact` / `references` / `inspect` / `analyze_symbol` all missed the dependency edge to the real `FooAttribute` class file. Added `ReferenceExtractorTests.Extract_CsharpGenericNoArgAttribute_ClassifiedAsAttribute`, `ReferenceExtractorTests.Extract_CsharpNestedGenericNoArgAttribute_ClassifiedAsAttribute`, `DbReaderTests.GetFileDependencies_CSharpGenericNoArgAttribute_StillIndexedAndResolvesToAttributeClass`, `DbReaderTests.GetFileDependencies_CSharpGenericNoArgAttribute_AssemblyTarget_IsIndexed`, and `DbReaderTests.GetFileDependencies_CSharpNestedGenericNoArgAttribute_ResolvesToAttributeClass` to pin the end-to-end behavior. Both the `impact` ambiguity guard (`IsMetadataTargetUnambiguous`) and the `deps` ambiguity path (`target_files` / `target_ambiguity`) are additionally tightened to treat a C# class as a valid `[Attr]` metadata target ONLY when its signature inherits from an `Attribute`-suffixed base class (`signature LIKE '%: %Attribute%'`). Plain `class MyAuditAttribute { }` without `: Attribute` inheritance is not a valid `[MyAudit]` target at compile time, so counting it as an ambiguity candidate would under-report the `impact` blast radius and suppress a real `deps` metadata edge when a same-named plain class coexists with the real attribute class. `target_files` now exposes a new `has_metadata_target_kind` MAX column that applies the same C# signature filter, and `metadata_raw_suppression` / `target_ambiguity` / the metadata clause in `edges` are all switched to `has_metadata_target_kind`; Java / Kotlin / Scala targets remain unaffected because their metadata markers cannot be captured with a single portable signature LIKE, so they keep the broader class-like candidate set. Added `DbReaderTests.GetFileDependencies_CSharp_PlainClassWithAttributeSuffixName_DoesNotCountAsAmbiguity` to pin the `deps` path. Round-17 follow-up loosens the C# metadata-target signature filter from `'%: %Attribute%'` (requires the immediate base class to end in `Attribute`) to `'%: %'` (any inheritance clause qualifies) so indirect inheritance like `class MyAuditAttribute : BaseAudit` where `BaseAudit : Attribute` is also recognized as a valid `[MyAudit]` target; transitive base-type resolution is not available at SQL time, so the inheritance-clause check is the best portable approximation — non-attribute derivations are over-accepted, but an invalid `[MyFoo]` against a non-attribute class would fail to compile and therefore does not appear as a real reference. The shared `BuildCSharpMetadataTargetFilter` helper additionally degrades to `1 = 1` (the pre-metadata-eligibility behavior) when the `symbols.signature` column is missing on legacy / read-only DBs where `TryMigrateForRead` could not add it, so `deps` / `impact` queries return results instead of crashing with `no such column: s.signature`. Added `DbReaderTests.GetFileDependencies_CSharp_IndirectAttributeInheritance_ResolvesAsMetadataTarget` to pin the indirect-inheritance path. Round-18 follow-up replaces the boolean `BuildCSharpMetadataTargetFilter` helper with a unified `BuildMetadataTargetKindExpr(fileAlias)` expression that narrows the metadata-target candidate kind per language: C# accepts only `kind = 'class'` with an inheritance clause (with the same legacy `1 = 1` fallback to class-only when `symbols.signature` is missing), JavaScript/TypeScript accept `class` / `struct` / `interface` / `function` (so function-decorator factories like `function sealed(target) { ... }` targeted by `@sealed` stay as valid `deps` edges), and other graph-supported languages keep the broader class/struct/interface candidate set. The old filter applied only the C# signature clause and left `s.kind IN ('class', 'struct', 'interface')` outside, which (a) wrongly counted `struct MyAuditAttribute` / `interface MyAuditAttribute` as C# metadata-target ambiguity candidates even though neither can derive from `System.Attribute`, and (b) dropped JS/TS decorator edges to a `function sealed` factory because `has_metadata_target_kind` required a class-like kind. The new expression is applied at three call sites: `IsMetadataTargetUnambiguous` (via a precomputed `metadataTargetKindExprF` local), the `target_files` CTE `has_metadata_target_kind` MAX column, and the `target_ambiguity` CTE JOIN condition (where the outer `s.kind IN (...)` narrowing is removed since the new expression already encodes the per-language kind rule). Added `DbReaderTests.GetFileDependencies_JavaScriptFunctionDecorator_ResolvesAsDependency` (JS `@sealed` class → `function sealed(target)` factory file now produces a deps edge instead of being dropped) and `DbReaderTests.GetFileDependencies_CSharp_SameNameInterface_DoesNotBlockMetadataEdge` (same-named `interface MyAuditAttribute` no longer ambiguates `[MyAudit]` → `class MyAuditAttribute : Attribute`). Round-19 follow-up additionally tightens the C# clause to accept rows whose `signature` is NULL when the column itself exists (`s.signature IS NULL OR s.signature LIKE '%: %'`): the previous `s.signature IS NOT NULL AND s.signature LIKE '%: %'` rejected every row on a DB whose schema was migrated in place by `TryMigrateForRead` without reindexing (the column is added but existing rows keep NULL signatures), so real `[MyAudit]` → `class MyAuditAttribute : System.Attribute` edges silently disappeared from `deps` / `impact` until the user re-ran `cdidx index`. Matching the spirit of the column-missing `1 = 1` fallback, NULL signatures are now treated as eligible instead of being rejected outright. Added `DbReaderTests.GetFileDependencies_CSharp_LegacyDbWithNullSignature_StillResolvesAttributeEdge` (NULL-signature attribute class still resolves the `deps` edge). Round-20 follow-up tightens the NULL-signature fallback to the canonical C# attribute naming convention (`name LIKE '%Attribute'`) instead of accepting every NULL-signature class, so a legacy-migration DB no longer counts arbitrary same-named non-attribute classes (`HelperClient : BaseService`) as metadata-target candidates that would silently inject false ambiguity against a real `[MyAudit]` → `MyAuditAttribute` edge. DBs without any `signature` column at all degrade to the same naming heuristic, and the existing `GetFileDependencies_CSharp_LegacyDbWithNullSignature_StillResolvesAttributeEdge` regression still passes because `MyAuditAttribute` ends with `Attribute` and therefore survives the tightened fallback. Round-20 also drops `interface` from the JavaScript / TypeScript metadata-target candidate kind set (`class` + `function` only): TypeScript `interface` is a compile-time type-only construct and cannot be a decorator target at runtime, so including it would let a same-named `interface` inject false ambiguity against a real `function` / `class` decorator provider and silently drop the `@sealed` → `function sealed(target)` deps edge. Added `DbReaderTests.GetFileDependencies_CSharp_LegacyDbWithNullSignature_NonAttributeName_DoesNotBlockMetadataEdge` (arbitrary NULL-signature class with a non-`Attribute` name no longer injects false ambiguity) and `DbReaderTests.GetFileDependencies_JavaScript_SameNameInterface_DoesNotBlockFunctionDecoratorEdge` (same-name `interface sealed` does not suppress the `@sealed` → `function sealed(target)` deps edge). The remaining edge case — a non-attribute class that already follows the `Attribute` naming convention or has an unrelated `: ` inheritance clause silently suppressing the real edge — is tracked as #435 and requires a schema-level `is_metadata_target` column to resolve cleanly, so it is deliberately deferred out of this PR to keep the #293 scope focused on the already-landed attribute / annotation classification work. Affected: `src/CodeIndex/Database/DbReader.cs`, `src/CodeIndex/Indexer/ReferenceExtractor.cs`, `tests/CodeIndex.Tests/DbReaderTests.cs`, `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`. +- **MCP `callers` / `callees` `kind` schema, CLI help, and README `--kind` option row no longer advertise metadata kinds as valid filter values (#293 follow-up)** — The `callers` / `callees` handlers reject `kind: "attribute"` / `kind: "annotation"` with a usage error (CLI) / `isError` tool response (MCP), but the MCP tool schema's `kind` property description still listed all five kinds as if they were valid filter values (`"Filter by reference kind (call, instantiate, subscribe, attribute, annotation)"`), `cdidx --help` similarly lumped `callers` / `callees` together with `references` for the `--kind` help row, and the README `--kind` option table (EN line 436 / JP line 1307) still directed readers to pass `--kind attribute` or `--kind annotation` on `callers` / `callees`. Schema and handler now agree: the `callers` / `callees` `kind` description now reads `"Filter by call-graph reference kind (call, instantiate, subscribe). Metadata kinds (attribute, annotation) are rejected here; use \`references\` for metadata enumeration."`, the CLI `--kind` help row splits `references` (accepts all five kinds) from `callers` / `callees` (call-graph kinds only, metadata rejected, redirects to `references`), and the README `--kind` row (both English and Japanese) now explicitly states that `callers` / `callees` reject `--kind attribute|annotation` with a usage error and points to `references --kind attribute|annotation` as the correct metadata-enumeration path. The MCP `references` tool description is unchanged because it still accepts every indexed kind. Added `McpServerTests.ToolsList_CallersCalleesKindDescription_ExcludesMetadataKinds` to pin the corrected schema wording. Affected: `src/CodeIndex/Mcp/McpToolDefinitions.cs`, `src/CodeIndex/Cli/ConsoleUi.cs`, `tests/CodeIndex.Tests/McpServerTests.cs`, `README.md`. +- **`deps`, `impact`, `references`, `inspect`, and `analyze_symbol` retain metadata references as compile-time dependency edges and canonicalize the C# attribute-suffix convention (#293 follow-up)** — `DbReader.GetFileDependencies` (backing `cdidx deps`) and `DbReader.GetFileDependencyHintsToResolvedType` (backing `cdidx impact` / MCP `impact_analysis` heuristic file-hint fallback) keep metadata references in their logical reference CTE. Renaming or removing an attribute class still breaks annotated sites such as `[JsonConverterAttribute]` at compile time, so file-level blast-radius analysis must surface those edges as real dependencies — now labeled `attribute` / `annotation` in `logical_reference_kind` (previously these same sites leaked in as phantom `call` rows before classification was corrected). `GetFileDependencies` additionally canonicalizes the C# attribute naming convention: idiomatic `[Foo]` usage is stored with `symbol_name = "Foo"` but the defining class is `FooAttribute`, so the logical-reference CTE now UNION-ALLs a suffix-aliased copy of every C# `attribute`-kind row with `'Attribute'` appended (only when the original symbol_name does not already end in `Attribute`) so the target-file join reaches the attribute class file. `GetFileDependencyHintsToResolvedType` also aggregates a per-candidate `has_metadata_ref` flag and bypasses the `SourceFileHasStructuredTypeEvidence` guard for edges whose contributing references are purely metadata, so pure-attribute consumer files such as `[MyAudit] class Svc` now surface through `impact MyAuditAttribute` instead of being filtered out. `ResolveImpactFallbackNames` symmetrically adds suffix-stripped aliases for C# class names ending in `Attribute`, so the BFS-less file-hint path can find `[Foo]` sites whose source symbol_name does not match the defining class name. The same canonicalization is propagated to the symbol-level reference reader: `DbReader.SearchReferences`, `CountSearchReferences`, and `CountSearchReferencesTotal` (backing `references`, `inspect`, MCP `analyze_symbol`) now compute a C#-only `queryAttributeAlias` (defined in the new `ComputeCSharpAttributeSuffixAlias` helper) and OR it into both exact (folded and NOCASE) and substring WHERE clauses, so `references MyAuditAttribute` (and `inspect MyAuditAttribute` / `analyze_symbol MyAuditAttribute`) surface the idiomatic `[MyAudit]` call site consistently with `deps` / `impact`. Substring mode uses an exact-OR for the alias (not a second LIKE) so user queries like `MyAuditAttribute` do not over-match unrelated names such as `FooAuditLog`. The alias is C# only, so Java / Kotlin / Scala / TypeScript annotation sites remain unaffected. `callers` / `callees` intentionally skip the alias because they filter to the shared call-graph kinds (`call`, `instantiate`, `subscribe`) and reject `--kind attribute|annotation` at the CLI / MCP boundary — those commands model the dynamic call graph, not the dependency graph. The alias branch is additionally scoped so it cannot produce false positives: `ComputeCSharpAttributeSuffixAlias` returns `null` when `referenceKind` is a non-`attribute` call-graph kind (so `references FooAttribute --kind call` stays strict and does not match `Foo()`), and the SQL alias disjunct in all three reader methods is clamped to `f.lang = 'csharp' AND r.reference_kind = 'attribute'` so an unscoped `references FooAttribute` (no `--lang` / no `--kind`) still cannot bleed into Java annotations or C# call rows. Suffix detection is also case-insensitive (`OrdinalIgnoreCase`) so `references myauditattribute` and `inspect MyAuditATTRIBUTE` still produce the `MyAudit` alias, consistent with the NOCASE / folded contract of the surrounding exact / substring query paths. Added regression coverage `DbReaderTests.GetFileDependencies_IncludesMetadataReferencesAsCompileTimeDependencies` (runtime + metadata edges both surface), `DbReaderTests.GetFileDependencies_MatchesCSharpAttributeSuffixConvention` (`[MyAudit]` → `MyAuditAttribute` canonicalization through `deps`), `DbReaderTests.SearchReferences_MatchesCSharpAttributeSuffixConvention_Substring` / `_Exact` / `_CSharpAttributeSuffixAliasDoesNotBleedToOtherLanguages` (same canonicalization through `references` / `inspect` / `analyze_symbol`, plus C#-only scope guard), `DbReaderTests.SearchReferences_CSharpAttributeSuffixAlias_NotAppliedToCallKind` (alias must not fire under `--kind call`), `DbReaderTests.SearchReferences_CSharpAttributeSuffixAlias_UnscopedLangStillLimitsToCSharpAttributeRows` (unscoped query must not match Java annotations or C# call rows), and `DbReaderTests.SearchReferences_CSharpAttributeSuffixAlias_CaseInsensitiveQuery` (lowercase / mixed-case queries still produce the alias). Affected: `src/CodeIndex/Database/DbReader.cs`, `tests/CodeIndex.Tests/DbReaderTests.cs`. +- **`callers` / `callees` now reject `--kind attribute|annotation` at the CLI and MCP boundary, and Swift / Gradle `@` metadata is reclassified as `annotation` (#293 follow-up)** — Metadata rows (`attribute` / `annotation`) are attributed to their enclosing body-range symbol rather than the annotated target, so `callers Obsolete --kind attribute` would silently return `[Obsolete] void M()` under the enclosing class (not `M`), and file-level targets such as `[assembly: CLSCompliant]` or Kotlin `@file:JvmName` would drop entirely because `container_name` is `NULL`. `QueryCommandRunner.RunCallers` / `RunCallees` now reject `--kind attribute` and `--kind annotation` with `CommandExitCodes.UsageError`, and the MCP `callers` / `callees` tools return an `isError` tool response; both paths direct users to `references --kind attribute|annotation`, which IS a correct metadata enumeration path. Separately, `AnnotationLanguages` now includes `swift` and `gradle`, so Swift `@available(iOS 13.0, *)` / `@objc` / `@MainActor` and Gradle/Groovy `@CompileStatic` / `@TaskAction` are recorded as `annotation` instead of `call` (previously the `@Name(args)` forms leaked into `callers` / `callees` / `hotspots` / `impact` as phantom call edges, and the no-arg `@objc` / `@MainActor` / `@CompileStatic` / `@TaskAction` forms dropped from the index entirely because no-arg annotation emission was gated to languages in this set). Added `QueryCommandRunnerTests.RunCallersCallees_RejectMetadataKind_WithUsageError` (theory, 4 cases), `McpServerTests.ToolsCall_CallersOrCallees_MetadataKindReturnsToolError` (theory, 4 cases), `ReferenceExtractorTests.Extract_SwiftAttributeWithArgs_ClassifiedAsAnnotation`, and `ReferenceExtractorTests.Extract_GradleAnnotation_ClassifiedAsAnnotation`. Affected: `src/CodeIndex/Cli/QueryCommandRunner.cs`, `src/CodeIndex/Mcp/McpToolHandlers.cs`, `src/CodeIndex/Indexer/ReferenceExtractor.cs`, `tests/CodeIndex.Tests/QueryCommandRunnerTests.cs`, `tests/CodeIndex.Tests/McpServerTests.cs`, `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`. +- **Multi-line no-arg C# attribute sections like `[\n Serializable\n]` are now indexed without over-matching attribute argument identifiers (#293 follow-up)** — The no-arg C# attribute regex previously required the match to start at a `[` / `,` boundary and end at `]` / `,` on the same line, so a bare identifier on the interior line of a multi-line attribute section (e.g. ` Serializable` between a line-leading `[` and a line-leading `]`) was silently dropped from `references --kind attribute`. The left anchor is now a word-boundary lookbehind and the right anchor also accepts end-of-line. To avoid misclassifying enum / qualified-constant identifiers that happen to sit at end-of-line inside an attribute argument list (e.g. `ConverterStrategy.AllowNumbers` in `[\n JsonConverter(\n ConverterStrategy.AllowNumbers\n )\n]`) as no-arg attributes, `BuildCSharpAttributeRanges` now also emits a parallel "top-level zone" table that splits each attribute section by `(` / `)` so only paren-depth-0 positions inside the section qualify as no-arg attribute name positions. Paren depth is tracked section-locally (each `[` snapshots the global depth at open time) so parameter attributes like `void M([FromServices] IService s)`, delegate parameter attributes like `delegate void D([Attr] int x)`, and lambda parameter attributes like `Func f = ([Attr] int x) => x` — whose `[` opens while the global paren depth is already > 0 — still have their attribute-list top level correctly identified. The no-arg attribute path uses that top-level table instead of the full attribute-section ranges, while the existing with-args metadata classification still uses the full ranges. `SymbolExtractor` also now tracks multi-line `[...]` depth across lines via `StripMultiLineCSharpAttributeInterior` in `BuildCSharpMatchLines`, so interior lines of a multi-line attribute section are blanked out before declaration regexes run — this prevents ` Serializable` from being mis-extracted as a top-level `function` symbol (which then masked the reference through the `definitionNames` guard). The multi-line blanker now activates on any `[` that opens without a matching `]` on the same line, not just `[` that sits at the start of the line, so multi-line parameter attributes like `void M([\n FromServices\n] IService s)`, type-parameter attributes like `class Bar<[\n TypeParamAttr\n] T>`, and delegate/lambda parameter attributes like `delegate void D([\n DelegateParamAttr\n] int x)` are also blanked out correctly — previously only the leading-`[` case was covered, so the interior identifiers of those forms were mis-extracted as `function` symbols and their attribute references were silently dropped from `references --kind attribute`. The blanker is now attribute-position aware as well: it only treats `[` as an attribute opener when the immediately preceding non-whitespace character is not a word character (`[_A-Za-z0-9]`) and not `)` / `]`, so multi-line indexer declarations like `public int this[\n int i\n] => _items[i];` (where `[` follows the `this` keyword) are correctly left as-is and the indexer still surfaces as a `function` with the canonical `Item` name in `symbols` / `definition` / `outline` / `inspect` / `unused` / `hotspots`. Regression coverage adds `[\n Serializable\n]`, `[\n global::System.Obsolete\n]`, `[Required,\n Key]`, `[\n JsonConverter(\n ConverterStrategy.AllowNumbers\n )\n]` (pinning that `AllowNumbers` / `ConverterStrategy` are NOT classified as `attribute`), the non-leading-`[` parameter / type-parameter / delegate-parameter multi-line cases above, and the multi-line indexer declaration (`SymbolExtractorTests.Extract_CSharp_DetectsMultiLineIndexer`), running through `SymbolExtractor.Extract` + `ReferenceExtractor.Extract` end-to-end so both sides of the regression stay pinned. Affected: `src/CodeIndex/Indexer/ReferenceExtractor.cs`, `src/CodeIndex/Indexer/SymbolExtractor.cs`, `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`, `tests/CodeIndex.Tests/SymbolExtractorTests.cs`. +- **`::`-qualified C# no-arg attributes and JavaScript `@Decorator` usages are now classified correctly (#293 follow-up)** — The no-arg C# attribute regex now accepts `::` alongside `.` as a qualifier separator, so `[global::System.Obsolete]` and `[Alias::MyAttr]` reach `references --kind attribute` instead of being silently dropped. `AnnotationLanguages` additionally includes `javascript`, so JS decorators such as `@sealed` (no-arg) and `@injectable()` (with args) are reclassified to `annotation` rather than leaking into the call-graph as phantom `call` rows — matching the existing TypeScript contract since JavaScript is already a graph-supported language. Added regression tests pinning `[global::System.Obsolete]`, `[Alias::MyAttr]`, and JavaScript `@sealed` / `@injectable()`. Also rewrote the README `callers` row (EN + JP) and the MCP tool descriptions for `callers` / `callees` to pull back the earlier over-promise that `kind: "attribute"` / `kind: "annotation"` on `callers` / `callees` is a supported metadata-inspection path: a metadata row is attributed to its enclosing body-range symbol (for a class-level declaration, that is the class itself) and drops entirely for file-level targets such as `[assembly: ...]` where `container_name` is `null`, so the docs now direct users to `references --kind attribute|annotation` for metadata enumeration. Affected: `src/CodeIndex/Indexer/ReferenceExtractor.cs`, `src/CodeIndex/Mcp/McpToolDefinitions.cs`, `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`, `README.md`. +- **`impact` BFS now follows C# event subscribe edges (#293 follow-up)** — `DbReader.GetCallersExact`, the only caller site used by the `impact` / `impact_analysis` BFS, now filters references through the shared `CallGraphReferenceKindsSql = ('call', 'instantiate', 'subscribe')` set instead of `InvokeReferenceKindsSql = ('call', 'instantiate')`. Previously `impact` quietly dropped `Changed += OnChanged` subscribe rows while `callers` / `callees` / `hotspots` kept them, so an event-driven transitive caller chain appeared shorter under `impact` than under `callers`. A new `DbReaderTests.GetTransitiveCallers_FollowsSubscribeEdges` regression pins the subscribe edge into the BFS output. Metadata edges (`attribute`, `annotation`) remain excluded. Affected: `src/CodeIndex/Database/DbReader.cs`, `tests/CodeIndex.Tests/DbReaderTests.cs`. +- **C# parameter, type-parameter, lambda, and multi-line `[...]` attribute lists are now classified as `attribute`, no-arg attributes and annotations are indexed, and MCP tool descriptions document the default metadata filter (#293 follow-up)** — `ReferenceExtractor` now runs a pre-pass that tokenizes every C# `[...]` section across the whole file, so parameter attributes such as `void M([FromServices] IService s)` (where `[` is preceded by `(` or `,` rather than a declaration boundary), type-parameter attributes such as `class C<[Attr("x")] T>` (preceded by `<`), tuple-typed parameter attributes such as `void M([Attr("x")] (int a, int b) value)` (where the next token after `]` is `(`), lambda attributes such as `var f = [Attr("x")] () => 0;` (where `[` is preceded by `=`), and multi-line forms such as `[\n Foo("x")\n]` or `public void M(\n [Attr("x")]\n int a)` are recognized alongside the existing same-line cases. The pre-pass disambiguates the `(` / `,` / `<` / `=` entry point against C# 12 collection expressions in argument position (`Consume([Make()])`), pattern expressions (`[Make()] is int[] xs`), `as` casts (`[Make()] as int[]`), and switch expressions (`[Make()] switch { _ => true }`) by scanning forward from `[` to the matching `]` and checking whether the next meaningful token begins a declaration — identifiers are accepted unless they are the expression-continuation keywords `is` / `as` / `switch` / `with` / `when`, `@` is accepted as a verbatim-identifier lead, `(` is accepted for tuple-typed parameters and lambda parameter lists, and chained `[A][B]` recurses through the inner bracket. Bare no-arg C# attributes such as `[Serializable]`, `[Obsolete]`, `[System.Obsolete]`, `[assembly: CLSCompliant]`, and `[Required, Key]`, plus bare no-arg Java-family annotations such as `@Deprecated`, `@Override`, `@org.junit.Test`, and `@field:Deprecated`, are now emitted through dedicated no-arg regexes since the original `CallRegex` requires `(` — the C# path still gates on the attribute-range pre-pass so indexer access such as `arr[i]` stays unclassified, and the annotation path uses a lookbehind `(?`, tuple-typed parameter `void M([Attr("x")] (int a, int b) value)`, lambda `var f = [Attr("x")] () => 0;`, the no-arg C# attribute set above, the no-arg Java `@Deprecated` / `@Override` / `@org.junit.Test` set, Kotlin `@field:Deprecated` plus the `return@foo` negative, indexer access staying unclassified, `[\n Foo("x")\n]`, the cross-line parameter attribute shape, the targeted `[return: ...]` shape, the defense-in-depth collection-expression-in-argument case, and the three expression-continuation cases `[Make()] is ...`, `[Make()] as int[]`, and `[Make()] switch { ... }`. Affected: `src/CodeIndex/Indexer/ReferenceExtractor.cs`, `src/CodeIndex/Mcp/McpToolDefinitions.cs`, `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`. +- **C# attribute and Java/Kotlin/Scala/TypeScript annotation usages no longer pollute the call-graph as phantom `call` rows (#293)** — `ReferenceExtractor` now reclassifies identifiers that appear inside C# `[...]` attribute lists (including direct `[Attr(args)]`, targeted `[return: Attr]` / `[assembly: Attr]` forms, and comma-separated `[Foo("a"), Bar("b")]` lists) as `attribute` references, and identifiers preceded by a Java-family `@` marker (optionally through a dotted qualifier chain such as `@org.junit.Test(args)`, or through a Kotlin use-site target such as `@field:Deprecated("msg")` / `@get:JsonName("x")` / `@file:JvmName("Foo")`) as `annotation` references. Attribute `[` detection is gated by a declaration-position guard so C# 12 collection expressions (`var xs = [Make(), Make()]`, `Consume([Make(), Make()])`, `return [Make(), Make()]`) and indexer accesses (`arr[Compute()]`, chained `arr[Compute()][Compute()]`, `matrix[Row()][Col()]`) keep their inner calls as `call` instead of being misclassified as metadata — when the `[` is preceded by another `]`, the walk-back now finds the matching `[` (skipping balanced parens) and re-checks that opening bracket's declaration position so `[A("x")][B("y")]` chained attribute lists still classify as `attribute` while `arr[i][Compute()]` stays `call`. Kotlin use-site target detection also handles the qualifier-chain combination `@field:com.example.Deprecated("msg")` / `@get:com.fasterxml.jackson.annotation.JsonProperty("x")` by unwinding the dotted qualifier first and then matching either `@` or a Kotlin `target:` + `@`. The shared graph filter `CallGraphReferenceKindsSql = ('call', 'instantiate', 'subscribe')` is applied to default-kind `callers`, `callees`, count queries, hotspot CTEs, and symbol-hotspot grouping, so `callers Obsolete`, `callers Deprecated`, `impact Conditional`, and `hotspots` no longer surface phantom caller edges or inflate hotspot counts for metadata-only references. `references` defaults keep every indexed reference kind visible so metadata usages remain inspectable; `references --kind attribute` / `--kind annotation` (and the MCP tool equivalents) explicitly surface metadata rows, and the CLI help / `--kind` validator / README now list the new reference kinds and document the new default contract for `callers` / `callees` / `hotspots` / `impact`. Regression coverage pins the new classification for `[Obsolete("msg")]`, `[return: NotNull("x")]`, `[Foo("a"), Bar("b")]`, `[AttributeUsage(...)]`, Java `@Deprecated(since="1.0")`, qualified `@org.junit.Test(...)`, Kotlin `@Deprecated("msg")`, Kotlin `@field:Deprecated` / `@get:JsonName` / `@file:JvmName`, ordinary method bodies still emitting `call`, and C# collection-expression / indexer call sites staying `call`. Affected: `src/CodeIndex/Indexer/ReferenceExtractor.cs`, `src/CodeIndex/Database/DbReader.cs`, `src/CodeIndex/Database/DbSymbolReader.cs`, `src/CodeIndex/Cli/QueryCommandRunner.cs`, `src/CodeIndex/Cli/ConsoleUi.cs`, `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`, `tests/CodeIndex.Tests/ConsoleUiTests.cs`, `README.md`, `DEVELOPER_GUIDE.md`. Closes #293. - **Multi-line string masking now covers Python triple-quoted, Rust raw, and JS/TS template literal bodies (#291)** — `StructuralLineMasker` previously only masked C# raw/verbatim/interpolated string bodies, so code-shaped fixture text inside Python `""" ... """` / `''' ... '''` (including `r`/`b`/`u`/`f` prefixes and their uppercase variants), Rust `r#"..."#` / `r##"..."##` (including `b`/`c` prefixes), and JavaScript/TypeScript template literals leaked into line-based regex extraction as phantom symbols and phantom `call` reference edges. The masker now dispatches per-language, blanks the body of each multi-line literal before regex matching, preserves `${...}` interpolation contents in JS/TS templates and `{expr}` contents in Python f-strings (with `{{` / `}}` treated as escaped literal braces) so real calls inside holes still produce reference edges, tracks nested-brace depth so object-literal braces inside holes are not mistaken for hole terminators (distinguishing expression braces such as `${({a:1} / 2)}` from arrow-body statement blocks such as `${(() => { if (x) {} /regex/.test(y); })()}` via a per-hole context stack), balance-masks the hole-closing `}` as whitespace so surrounding JS/TS brace counting (e.g. function bodies containing template literals) is not thrown off, keeps top-level `}` regex-legal so `if (x) {} /.../` after an ordinary block does not flip into division and swallow the following backtick as a phantom template opener, tracks statement-head paren context (`if`/`while`/`for`/`switch`/`catch`/`with`) so `if (x) /regex/` and the same pattern inside template holes classify `/` as a regex literal rather than division (which previously let the regex body's backtick be read as a phantom template opener), carries JS/TS lex state across lines so template-hole division continuations retain correct operator/regex classification, supports Rust nested `/* */` block comments so `r"..."` bodies inside commented-out code do not leak, distinguishes Rust `'X'` char literals and escapes from `'lifetime` identifiers, preserves nested Python triple-quoted strings inside f-string holes by masking their contents to spaces (so indentation-sensitive body detection still sees blank lines), and for nested triple-quoted *f*-strings inside outer holes (e.g. `f"""{format(f"""{real_call()}""")}"""`) now tracks the nested f-prefix and its own inner-hole brace depth so the inner `{expr}` survives as a call edge instead of being blanked with the surrounding body, actively blanks the quote characters and non-hole body of single-line nested Python f-strings inside an outer hole so the downstream `StringLiteralRegex` in `ReferenceExtractor.PrepareLine` does not strip the inner call edge together with the literal, and skips string literals inside that inner hole so `{` / `}` appearing inside a nested quoted token (e.g. `f"{prefix('}') + real_call()}"`) do not close the inner hole prematurely. Also skips JS/TS regex literals so a backtick or `}` inside a regex body does not open a phantom template or close an interpolation hole early. Added regression coverage in `SymbolExtractor` + `ReferenceExtractor` for each new language, including Python f-string interpolation, nested Python triple-quoted / nested single-line f-strings inside outer holes (including a nested inner hole whose quoted token contains `}`), Rust raw strings inside block comments, JS regex literals, JS template-hole multi-line division continuations, JS inner-object-close division disambiguation, JS top-level block-close + regex literal, JS `if (x) /regex/` at top level and inside an arrow-body template hole, JS arrow-body block-close + regex literal inside a template hole, and JS postfix `++` / `--` followed by `/` inside a template hole (previously the second `+` / `-` character of the 2-char operator was classified as a generic token, so the following `/` started a phantom regex that swallowed the division operand and the subsequent call), and JS/TS `class Foo {}` (including `class Foo extends Bar {}` and anonymous `class {}`) inside a template hole, which must open a statement block rather than an object-literal expression brace so the matching `}` keeps `/regex/` regex-legal instead of flipping it to division and reading a backtick inside the regex as a phantom template opener, and JS/TS `switch (x) { case N: {} /regex/.test(x); }` and optional-binding `try {} catch {} /regex/.test(x);` inside a template hole — the `{` following `case N:` / `default:` now opens a statement block (not an object-literal expression brace) via a one-shot `CaseColonBlockPending` flag, the case-label `:` is anchored to the paren depth captured when the `case` / `default` keyword was seen (instead of requiring paren depth 0, which is never true when the enclosing template hole is wrapped in `(...)` such as `${(() => { switch (v) { case 1: {} /regex/.test(v); })()}`), and `catch` joins `else` / `do` / `try` / `finally` as a block-opening keyword so ES2019 optional-binding `catch {}` is not misclassified as an object-literal expression brace and a following `/regex/` stays regex-legal, and JS/TS `if (\`${x}\`) /`/.test(x); realCall();` plus its inside-a-template-hole variant ``${(() => { if (`${x}`) /`/.test(x); runTask(); })()}`` — `JsTemplateLiteralFrame` now snapshots the enclosing `JsLexState` (paren stack, class-header / case-label hints, previous token kind) on push and restores it on the closing backtick, so the statement-head `(` of `if` / `while` / `for` / `switch` / `catch` / `with` carried into the expression is not lost when the template literal is reset for its body, meaning the `)` after the template stays `StatementHeadCloseParen`, the following `/` is classified as a regex literal, and the regex body's backtick is not read as a phantom template opener that swallows the real `realCall()` / `runTask()` edge, and Rust nested `/* /* ... */ ... */` block comments plus JS/TS template-hole block comments such as ``${/* fake(); */ realCall()}`` — the masker now blanks the comment body together with its `/*` / `*/` delimiters so identifiers buried inside a nested Rust comment (whose outer closer the downstream non-nesting comment stripper swallows) and identifiers buried inside a template-hole block comment (whose multi-line body is preserved verbatim by the hole) no longer leak as phantom call references, while the real call following the comment remains attributed to the surrounding container, and a triple-quoted string that appears inside the *inner* hole of a nested triple f-string (`f"""{format(f"""{len('''\n}\n''') + real_call()}""")}"""`) — the inner-hole scanner now detects `'''` / `"""` openers up front and tracks their closing triple across lines, because `SkipPythonSingleLineString` only matches a quote on the same line and would otherwise leave stray quote characters behind that caused the next line's `}` to be mistaken for the inner hole closer, dropping the post-triple `real_call()` edge, and TypeScript `enum Local {}` / `interface Local {}` / `namespace Local {}` / `module Local {}` inside a template hole — these declaration-body keywords now share the statement-block classification previously reserved for `class`, so the matching `}` keeps a following `/regex/` regex-legal instead of flipping to division and reading the regex body's backtick as a phantom template opener that erased calls such as `runTask()` after ``${(() => { enum Local { A } /`/.test(value); runTask(); })()}``, and the *inner* `{expr}` hole of a nested *single-line* Python f-string (outer `f"""` → its hole → nested single-line `f""`) that itself contains a multi-line `'''...'''` triple — the nested-single-line helper used to be line-local and returned end-of-line without tracking that its inner hole (or the triple-quoted string opened inside that inner hole) was still open, so the `}` on the next line closed the outer hole and the `real_call()` following the triple's close was swallowed as outer f-string body; the nested single-line f-string's quote, inner-hole brace depth, and any triple-quoted string opened inside the inner hole are now carried across lines alongside the outer-triple's own state, plus a CLI integration test verifying that phantom symbols are suppressed while interpolation-hole calls remain attributed to the correct container. Affected: `src/CodeIndex/Indexer/StructuralLineMasker.cs`, `tests/CodeIndex.Tests/SymbolExtractorTests.cs`, `tests/CodeIndex.Tests/ReferenceExtractorTests.cs`, `tests/CodeIndex.Tests/QueryCommandRunnerTests.cs`. Closes #291. - **C# wrapped constructor initializer `: base(...)` / `: this(...)` no longer leaks phantom `function base` / `function this` symbols (#331)** — The shared C# `CSharpTypePattern` character class intentionally includes `:` so that alias-qualified return types like `Alias::Type IFoo.Create()` keep matching the explicit-interface implementation path. That makes the method regex vulnerable to wrapped Allman-style constructor initializers such as ` : base(s, 0)` or ` : this(a)` — a future tweak to the existing first-char `(?![?:])` guard could let `returnType=":" + name="base"` slip back in as a phantom `function base` / `function this` symbol. The method regex now carries an additional `(?!(?:base|this)\b)` negative lookahead right before the `name` capture so the phantom cannot surface even if the first-char guard weakens later, while the dedicated indexer pattern continues to match `this[...]` by name. Added a `SymbolExtractor` regression test that pins both the wrapped `: base(...)` / `: this(...)` forms and the same-line plus expression-bodied variants, asserting all five constructors in the fixture still index and neither `function base` nor `function this` is emitted. Affected: `src/CodeIndex/Indexer/SymbolExtractor.cs`, `tests/CodeIndex.Tests/SymbolExtractorTests.cs`. Closes #331. - **Java tab-indented `enum` members now index as symbols without false positives (#364, #292)** — `SymbolExtractor` already relaxed the C# enum-member regex from `^\s{2,}` to `^\s+` via #214, so tab-indented C# members like `\tRed,` are captured and the existing `Extract_CSharp_DetectsTabIndentedEnumMembers` regression pins that behavior. The parallel Java enum-member regex still required `^\s{2,}`, so a single tab of indentation (common with EditorConfig `indent_style=tab` or legacy IDE defaults) silently dropped every Java enum member — the enum shell surfaced as a `Color` symbol but `RED` / `GREEN` / `BLUE` were invisible to `symbols`, `definition`, `references`, `callers`, and `callees`. Rather than relaxing the leading-whitespace count (which would also catch tab-indented `\tRED();` method calls inside a class body as phantom enum members, worsening #292), the Java enum-member regex was removed and replaced with a body-scoped scanner (`ExtractJavaEnumMembers`) modeled on the existing C# approach. The scanner walks the enum body tracking strings, char literals, line/block comments, Java 15+ text blocks, parens, brackets, and nested braces (for anonymous member bodies), emits each member at top-level `,` boundaries, and stops at the first top-level `;`. Member-name extraction skips leading `@Annotation(...)` forms using a lex-aware annotation skip so annotations with quoted parens / block comments / text blocks don't derail binding, and `@Deprecated A(1)` still binds to `A`. The enum body range itself is now resolved by a new `FindJavaBraceRange` that reuses the same lexer state machine, so a `}` inside a text block or quoted string no longer prematurely closes the enum body and drops every member after the literal. When the primary scanner exits with unbalanced paren/bracket depths (signalling a malformed annotation like `@Ann(` that spans a partial edit), a bounded line-regex recovery pass emits obvious uppercase-identifier members; recovery tracks brace depth across the body so lines inside anonymous member bodies or methods aren't mis-emitted as phantom members, dedups by member name (since the primary scanner stamps `StartLine` at the annotation line while recovery stamps the member-name line, StartLine-based dedup would double-emit), and terminates on a top-level `;`. The member-name regex accepts Unicode identifiers per JLS §3.8, so enum members like `RÉSUMÉ` or `NAÏVE` are captured intact instead of truncated at the first non-ASCII character. Added regression tests: `Extract_Java_DoesNotExtractMethodCallsAsEnumMembers` pins that `\tRED();` / `\tGREEN();` inside a class body are not symbolized, `Extract_Java_StopsEnumMembersAtSemicolon` pins that declarations after the first top-level `;` are not added as enum members, `Extract_Java_HandlesAnnotationWithQuotedParen` / `Extract_Java_HandlesBlockCommentBetweenAnnotationAndMember` pin the lex-aware annotation skip, `Extract_Java_RecoversMembersWhenAnnotationIsMalformed` / `Extract_Java_HandlesEmptyEnumBody` / `Extract_Java_HandlesEnumWithOnlySemicolon` / `Extract_Java_HandlesTrailingComma` / `Extract_Java_HandlesAnonymousMemberBody` pin the bounded recovery and edge-case body shapes, `Extract_Java_RecoveryIgnoresLinesInsideAnonymousMemberBody` / `Extract_Java_RecoveryDedupsByNameAcrossAnnotationStartLines` pin the brace-depth-aware recovery and name-based dedup, `Extract_Java_DetectsUnicodeEnumMembers` pins Unicode member names, and `Extract_Java_HandlesTextBlockContainingBrace` / `Extract_Java_HandlesStringContainingBrace` pin the lex-aware body-range resolution. Existing tab / 2-space / static-final / declaration coverage remains intact. Affected: `src/CodeIndex/Indexer/SymbolExtractor.cs`, `tests/CodeIndex.Tests/SymbolExtractorTests.cs`, `DEVELOPER_GUIDE.md`. Closes #364, #292. @@ -749,6 +758,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **DEVELOPER_GUIDE が SQLite の位置付けとリリース番号の例示を明確化** — cdidx が「設定ゼロ・単一ファイル・本番依存1個」の CLI であり、その唯一の本番依存が `Microsoft.Data.Sqlite` であることを明記した。あわせて、リリース手順のチェックリスト中に出てくる `1.9.0` は固定値ではなく例示であり、実際のリリース番号へ読み替える前提だと明示した。対象: `DEVELOPER_GUIDE.md`。 #### 修正 +- **`deps` の C# attribute サフィックス別名を class-like ターゲットに限定し、`impact` の metadata bypass も class-like ターゲット名が名前空間をまたいで曖昧な場合は抑止するよう修正 (#293 追加対応)** — 直前の #293 追加対応では、`deps` の logical-reference CTE に C# 限定で `symbol_name || 'Attribute'` のサフィックス別名を UNION することで `[Foo]` の attribute consumer が `FooAttribute` クラスファイルへ到達するようにし、`impact`(`GetFileDependencyHintsToResolvedType`)も寄与参照に `attribute` / `annotation` が含まれていれば structured-evidence guard をバイパスするようにしていたが、どちらも広すぎた。`deps` 側では別名が純粋に名前だけで join していたため、`[Foo]` が同名の関数・プロパティ・変数(例: 無関係なヘルパーの `void MyAuditAttribute()`)を定義するファイルにも依存を張ってしまっていた。`impact` 側の metadata bypass も、対象名が一意に class-like 定義を指すかを確認していなかったため、`MyAuditAttribute` のような短い名前が複数の namespace に存在すると rename / remove の blast-radius が過剰報告されていた(`attribute` / `annotation` 参照行は短い名前しか保持できず曖昧性解消ができない)。`DbReader.GetFileDependencies` は合成された別名行に `is_attribute_alias = 1`(本物の行は `0`)をタグ付けし、`source_name_counts` では `MIN` でフラグを伝播し、`edges` CTE の join で別名 edge をターゲット側 kind が `class` / `struct` / `interface` のときだけに制限するようにした。`target_files` CTE も `DISTINCT (path, lang, name, kind)` から `GROUP BY (path, lang, name)` + `MAX(...)` の `has_class_like_kind` フラグに切り替えたため、class と同名関数(例: C# コンストラクタ `Target()` と `class Target`)を同じファイルで定義しているケースでもターゲット行が 2 本に増えず `deps` の参照数を水増ししない。`DbReader.GetFileDependencyHintsToResolvedType` は新ヘルパー `IsMetadataTargetUnambiguous(definition, lang, pathPatterns, excludePathPatterns, excludeTests)` を呼び、*active な impact スコープ内* で同名の class-like 定義数を数える(graph 対応言語に加えて、呼び出し側の `--lang` / `--path` / `--exclude-path` / `--exclude-tests` を同じフィルタで適用)。in-scope でその数が 1 以下のときに限り metadata evidence-guard bypass を適用する。スコープ内で曖昧なときは metadata 単独 edge も通常の `SourceFileHasStructuredTypeEvidence` に戻り、純 `[MyAudit]` consumer は誤帰属せずに落ちる。スコープ尊重が重要: 別言語 / 別 path / test ツリーにある同名クラスが C# の metadata bypass を潰してはならない(参照側が既に graph 対応 join で言語修飾されているため、曖昧性は scope 内で判定すべき)。README の `impact` 契約も「active な `--lang` / `--path` / `--exclude-path` / `--exclude-tests` スコープ」と明記している。既存の C# attribute deps テスト `GetFileDependencies_MatchesCSharpAttributeSuffixConvention`、曖昧ではない単一定義の `impact` テスト、および既存の graph-queries dedupe テストは全てグリーンのまま。回帰テストとして `DbReaderTests.GetFileDependencies_CSharpAttributeAliasOnlyMatchesClassLikeTargets`(同名関数があっても虚偽 edge が出ないこと)、`DbReaderTests.GetFileDependencyHints_SuppressesCSharpAttributeMetadataBypassOnAmbiguousTarget`(impact スコープ内で `MyAuditAttribute` が複数ある場合に metadata heuristic hint を抑止)、`DbReaderTests.GetFileDependencyHints_CSharpAttributeMetadataBypassAppliesWhenTargetUnambiguous`(単一クラス定義のみのときは従来どおり `[MyAudit]` consumer を浮かせる)、`DbReaderTests.GetFileDependencyHints_MetadataBypassAmbiguityGuard_RespectsLangScope`(別言語の同名 annotation は `--lang csharp` クエリを潰さない)、`DbReaderTests.GetFileDependencyHints_MetadataBypassAmbiguityGuard_RespectsPathScope`(スコープ外 `src/B/` の同名クラスは `src/A/` クエリを潰さない)、`DbReaderTests.GetFileDependencyHints_MetadataBypassAmbiguityGuard_RespectsExcludeTests`(test ツリーの同名クラスは `--exclude-tests` クエリを潰さない)を追加。`ResolveImpactFallbackNames` も併せて引き締め、C# の `Attribute` サフィックス別名は *解決済み定義自身* の名前にのみ適用するようにした: 以前は同一ファイル内の fallback 名全てに別名を付けていたため、`FooAttribute` を定義するファイルに nested で `BarAttribute` が存在するだけで `impact FooAttribute` が `[Bar]` 利用サイトを `FooAttribute` の影響範囲として誤報告していた。`DbReaderTests.GetFileDependencyHints_CSharpAttributeSuffixAlias_DoesNotLeakToSameFileSiblings` を追加して固定。さらに `GetFileDependencies` を再構成し、metadata 行(`attribute` / `annotation`)が bare-name の call-graph ターゲットに漏れないようにした: `logical_references` CTE に `is_metadata` フラグを付与し、`source_name_counts` の GROUP BY を `(source_file_id, source_path, source_lang, symbol_name, is_attribute_alias, is_metadata)` に拡張して metadata と非 metadata で同じ symbol_name の行を別グループ化する。`edges` CTE は全 metadata edge(合成別名だけでなく実体行も)に `tf.has_class_like_kind = 1` を要求し、さらに 2 つの CTE で残る誤検知を潰す: `metadata_raw_suppression` は同じ source file で合成別名 `FooAttribute` が class-like ターゲットに解決できているとき、生の C# `[Foo]` 行を落とす(`[Foo]` が `class FooAttribute` と無関係な同名 `class Foo` に fan-out しない)。`target_ambiguity` はスコープ内で同名 class-like ターゲットが複数あるとき metadata edge を落とす(曖昧な `[MyAudit]` は deps で fan-out させず、`impact` / `references` に判断を委ねる)。既存の `GetFileDependencies_IncludesMetadataReferencesAsCompileTimeDependencies`(`[JsonConverter(...)]` → `class JsonConverter : Attribute` をターゲット名に `Attribute` サフィックスがなくても固定)は、class-like ターゲットが 1 つしかないので `target_ambiguity` で残り、`is_metadata` / class-like 制限も受理し、合成 `JsonConverterAttribute` 別名は class-like に解決できないので `metadata_raw_suppression` も発火せず、そのまま通る。回帰テストとして `DbReaderTests.GetFileDependencies_CSharpAttributeRawDoesNotLeakToBareNameClass_WhenSuffixTargetExists`(別名が `class MyAuditAttribute` に解決できたとき生 `[MyAudit]` 行は抑止され、無関係な plain `class MyAudit` ファイルに依存しない)、`DbReaderTests.GetFileDependencies_CSharpAttributeDoesNotLeakToSameNameMethodOrProperty`(完全形 `[MyAuditAttribute]` は同名メソッド / プロパティしか持たないファイルに依存しない)、`DbReaderTests.GetFileDependencies_CSharpAttributeDoesNotFanOutWhenMultipleSameNameAttributeClasses`(別名前空間に同名 attribute クラスが 2 つあると metadata edge はあいまいとして落とす)を追加。加えて `impact` の曖昧性ガード (`IsMetadataTargetUnambiguous`) と `deps` の曖昧性 CTE (`target_ambiguity`) も、path 単位ではなく symbol identity 単位で class-like 定義を数えるように引き締めた: `IsMetadataTargetUnambiguous` は `SELECT DISTINCT f.path, s.line, s.name` に変更し、C# でよくある 1 つの .cs ファイル内に別名前空間で同名 class-like が 2 つ定義されている形 (`namespace A { class FooAttribute { } } namespace B { class FooAttribute { } }`) でも ambiguity を 2 として検出する。`target_ambiguity` は `target_files` を `files` + `symbols` に JOIN し直して `COUNT(*)` を class-like symbol 行に対して取るため、同じ同名ファイル内重複ケースでも `[Foo]` metadata edge を正しく落とせる。path 単位カウントでは `SELECT DISTINCT f.path` も `target_files`(`dst.path` で GROUP BY)も同一ファイル内の 2 定義を 1 行に潰してしまうため、従来は count=1 として曖昧性を取りこぼし、`cdidx deps` と `cdidx impact` が同名 short-name metadata 参照を確定的な依存 / blast-radius edge として出していた。これは曖昧性に "duplicates in one file" を含める前提の設計契約に反していた。`deps` と `impact` 両方で symbol-identity 単位カウントを固定する `DbReaderTests.GetFileDependencies_CSharpAttributeAmbiguityCountsSameFileDuplicateClassDefinitions` と `DbReaderTests.GetFileDependencyHints_MetadataBypassAmbiguityGuard_CountsSameFileDuplicateDefinitions` を追加。さらに `IsMetadataTargetUnambiguous` の `--path` / `--exclude-path` パラメータを、他の reader 経路(search / references / deps 等)と同じ LIKE セマンティクスに揃えるため `EscapeLikeQuery` でエスケープしたうえで `%...%` で包んでバインドするようにした。生値のままだと `--path src/A/` のような CLI 形では LIKE が一致せず、リクエストされたサブツリーに同名 attribute クラスが 2 件並んでいても in-scope class-like カウントが 1 に過小化され、本来抑止すべき metadata bypass が誤発火してしまっていた。`DbReaderTests.GetFileDependencyHints_MetadataBypassAmbiguityGuard_CliPathPatternEscaping_SuppressesWhenInScopeIsAmbiguous` を追加して固定する。あわせて `CSharpNoArgAttributeRegex` を拡張し、属性名の直後に任意のジェネリック引数リスト `(?:\s*<[^\n]+?>)?` を許容するようにしたため、`[MyAudit]`・`[assembly: MyAttr]`・複数行の `[\n MyAttr\n]`・ネストジェネリックの `[MyAttr>]` や `[MyOther>>]` のような引数なしジェネリック属性も `attribute` 参照としてインデックスされるようになった。内側ジェネリックセグメントは非貪欲な `[^\n]+?` に変更し、内側の `>`(例: `Dictionary` を閉じる `>`)で match が打ち切られないようにした — 従来の `[^>\n]+` は `>` を除外していたため最初の内側 `>` で崩れ、ネストジェネリック属性の参照行が落ちていた。従来は `CallRegex`(`(` 必須)でも旧 no-arg regex(`]` / `,` / EOL で終端しなければならず、名前と `]` の間に `` があると拒否)でも拾えず、参照行が 0 件となり、`deps` / `impact` / `references` / `inspect` / `analyze_symbol` が実属性クラス `FooAttribute` への依存エッジを全て取りこぼしていた。`ReferenceExtractorTests.Extract_CsharpGenericNoArgAttribute_ClassifiedAsAttribute`、`ReferenceExtractorTests.Extract_CsharpNestedGenericNoArgAttribute_ClassifiedAsAttribute`、`DbReaderTests.GetFileDependencies_CSharpGenericNoArgAttribute_StillIndexedAndResolvesToAttributeClass`、`DbReaderTests.GetFileDependencies_CSharpGenericNoArgAttribute_AssemblyTarget_IsIndexed`、`DbReaderTests.GetFileDependencies_CSharpNestedGenericNoArgAttribute_ResolvesToAttributeClass` を追加して end-to-end の振る舞いを固定する。加えて、`impact` の曖昧性ガード (`IsMetadataTargetUnambiguous`) と `deps` の曖昧性判定 (`target_files` / `target_ambiguity`) は、C# クラスが有効な `[Attr]` metadata target になるのは「signature が `Attribute` サフィックス付き基底を継承している」場合 (`signature LIKE '%: %Attribute%'`) に限定するよう引き締めた。`: Attribute` 継承を書いていない plain `class MyAuditAttribute { }` はコンパイル時に `[MyAudit]` の target にならないため、曖昧性候補として数えると `impact` の影響範囲が過少報告され、`deps` 側でも同名 plain クラスが共存する scene で実 attribute クラスへの metadata edge が落ちてしまう。`target_files` に新しい `has_metadata_target_kind` MAX 列を追加して同じ C# signature フィルタを適用し、`metadata_raw_suppression` / `target_ambiguity` / `edges` の metadata 節はすべて `has_metadata_target_kind` に切り替えた。Java / Kotlin / Scala は portable な signature LIKE で metadata marker を表現しづらいため従来通り class-like 全体を候補に残す。`deps` 側の挙動を固定する `DbReaderTests.GetFileDependencies_CSharp_PlainClassWithAttributeSuffixName_DoesNotCountAsAmbiguity` を追加。Round-17 追加対応では、C# metadata target の signature フィルタを `'%: %Attribute%'`(直接基底が `Attribute` で終わることを要求)から `'%: %'`(何らかの継承節を持てば候補)に緩和した。`class MyAuditAttribute : BaseAudit` で `BaseAudit : Attribute` のような間接継承でも `[MyAudit]` の有効な target として認識される。SQL 時点で基底型を遡ることはできないため、継承節チェックは portable な近似として採用する — 非 attribute 由来のクラスは過剰に受理するが、無効な `[MyFoo]` をコンパイルすることはできず実参照にはならないので実害はない。共有ヘルパー `BuildCSharpMetadataTargetFilter` はさらに、`TryMigrateForRead` が追加できなかった legacy / read-only DB で `symbols.signature` 列が欠落しているときは `1 = 1`(metadata-eligibility 導入前の挙動)へ degrade するので、`deps` / `impact` クエリは `no such column: s.signature` でクラッシュせず結果を返す。間接継承パスを固定する `DbReaderTests.GetFileDependencies_CSharp_IndirectAttributeInheritance_ResolvesAsMetadataTarget` を追加。Round-18 では boolean の `BuildCSharpMetadataTargetFilter` ヘルパーを、ファイル別エイリアスを取り言語ごとに metadata-target 候補 kind を絞る `BuildMetadataTargetKindExpr(fileAlias)` 式へ置き換える。C# は継承節を持つ `kind = 'class'` のみ許可(`symbols.signature` が欠落する legacy DB では従来通り class-only にフォールバック)、JavaScript / TypeScript は `class` / `struct` / `interface` / `function` を許可(`function sealed(target) { ... }` のような factory 関数を `@sealed` が target とする decorator edge が `deps` に残る)、その他の graph 対応言語は従来通り `class` / `struct` / `interface` を受ける。旧フィルタは C# の signature 句だけを適用し、`s.kind IN ('class', 'struct', 'interface')` が外側に残っていたため、(a) C# で `struct MyAuditAttribute` / `interface MyAuditAttribute` が `System.Attribute` を継承できないにも関わらず metadata-target の ambiguity 候補として数えられ、(b) JS/TS で decorator の対象が `function sealed` factory の場合に `has_metadata_target_kind` が class-like を要求するために deps edge が落ちていた。新しい式は 3 箇所で使う: `IsMetadataTargetUnambiguous`(事前計算の `metadataTargetKindExprF` 経由)、`target_files` CTE の `has_metadata_target_kind` MAX 列、`target_ambiguity` CTE の JOIN 条件(言語ごとの kind narrowing が式に内包されたため、外側の `s.kind IN (...)` 絞り込みは削除)。`DbReaderTests.GetFileDependencies_JavaScriptFunctionDecorator_ResolvesAsDependency`(JS の `@sealed` class → `function sealed(target)` factory ファイルが落ちずに deps edge を生成)と `DbReaderTests.GetFileDependencies_CSharp_SameNameInterface_DoesNotBlockMetadataEdge`(同名の `interface MyAuditAttribute` が `[MyAudit]` → `class MyAuditAttribute : Attribute` を ambiguity 扱いにして抑止しない)を追加。Round-19 の追加では、C# 句を `s.signature IS NULL OR s.signature LIKE '%: %'` に緩め、`signature` 列は存在するが row の値が NULL の DB(`TryMigrateForRead` でその場 migration された列を持つが再インデックスを行っていない DB。既存行は NULL のまま)でも degrade するようにする。旧 `s.signature IS NOT NULL AND s.signature LIKE '%: %'` ではそうした DB で全行が reject され、本物の `[MyAudit]` → `class MyAuditAttribute : System.Attribute` edge がユーザーが `cdidx index` を再実行するまで silent に `deps` / `impact` から消えていた。列欠落時の `1 = 1` fallback と同じ精神で NULL signature を eligible 扱いにする。`DbReaderTests.GetFileDependencies_CSharp_LegacyDbWithNullSignature_StillResolvesAttributeEdge`(NULL signature の attribute class でも deps edge が解決する)を追加。Round-20 の追加対応では、NULL signature 時の fallback を「C# attribute 命名規約(`name LIKE '%Attribute'`)」にさらに絞り込み、NULL signature の非 attribute クラスを metadata-target 候補から除外する。`HelperClient : BaseService` のように `Attribute` サフィックスを持たないクラスは真の `[MyAudit]` → `MyAuditAttribute` edge に対する偽の曖昧性候補として数えられなくなり、legacy-migration DB でも silent に metadata edge が落ちない。`signature` 列自体が欠落している DB でも同じ命名規約 fallback に degrade する。既存の `GetFileDependencies_CSharp_LegacyDbWithNullSignature_StillResolvesAttributeEdge` 回帰は `MyAuditAttribute` が `Attribute` サフィックスを満たすので引き続きグリーン。加えて Round-20 では JavaScript / TypeScript の metadata-target 候補 kind から `interface` を外し(`class` + `function` のみ)、TypeScript の `interface` はコンパイル時の型専用構成子で runtime の decorator target になれないため、同名 `interface` が真の `function` / `class` decorator provider への edge を偽の曖昧性で潰す事態を防ぐ。`DbReaderTests.GetFileDependencies_CSharp_LegacyDbWithNullSignature_NonAttributeName_DoesNotBlockMetadataEdge`(NULL signature・非 `Attribute` 命名の無関係クラスが metadata edge を落とさない)と `DbReaderTests.GetFileDependencies_JavaScript_SameNameInterface_DoesNotBlockFunctionDecoratorEdge`(同名 `interface sealed` が `@sealed` → `function sealed(target)` の deps edge を抑止しない)を追加。残る edge case — 真の attribute class と同名の non-attribute class が `LIKE '%: %'` の過剰マッチで実 edge を silent に抑止する問題 — は schema レベルの `is_metadata_target` 列が必要になるため、#293 PR の scope を広げないために #435 で追跡する別 issue として意図的に deferred。対象: `src/CodeIndex/Database/DbReader.cs`、`src/CodeIndex/Indexer/ReferenceExtractor.cs`、`tests/CodeIndex.Tests/DbReaderTests.cs`、`tests/CodeIndex.Tests/ReferenceExtractorTests.cs`。 +- **MCP の `callers` / `callees` の `kind` schema・CLI help・README の `--kind` オプション行が metadata kinds を有効フィルタ値として広告しないよう修正 (#293 追加対応)** — `callers` / `callees` の handler は `kind: "attribute"` / `kind: "annotation"` を usage error(CLI)/ `isError` ツールレスポンス(MCP)で拒否するにもかかわらず、MCP ツール schema の `kind` プロパティ説明は 5 種すべてを列挙した `"Filter by reference kind (call, instantiate, subscribe, attribute, annotation)"` のままで、`cdidx --help` の `--kind` 行も `callers` / `callees` を `references` と同じ行にまとめていた。さらに README の `--kind` オプション表(英語 436 行目 / 日本語 1307 行目)も、`callers` / `callees` で `--kind attribute` / `--kind annotation` を渡せるかのように案内していた。schema と handler の契約を一致させ、`callers` / `callees` の `kind` description を `"Filter by call-graph reference kind (call, instantiate, subscribe). Metadata kinds (attribute, annotation) are rejected here; use \`references\` for metadata enumeration."` に修正し、CLI の `--kind` help 行も `references`(5 種すべて受理)と `callers` / `callees`(call-graph 種別のみ、metadata kinds は拒否して `references` へ誘導)を分けるようにし、README の `--kind` 行(英語・日本語の両方)も `callers` / `callees` が `--kind attribute|annotation` を usage error で拒否すること、metadata 列挙の正規経路が `references --kind attribute|annotation` であることを明示する文言に直した。MCP の `references` ツール説明は全 reference kind を受け付ける契約のままなので変更なし。修正後の schema 文言を固定する `McpServerTests.ToolsList_CallersCalleesKindDescription_ExcludesMetadataKinds` を追加。対象: `src/CodeIndex/Mcp/McpToolDefinitions.cs`、`src/CodeIndex/Cli/ConsoleUi.cs`、`tests/CodeIndex.Tests/McpServerTests.cs`、`README.md`。 +- **`deps`・`impact`・`references`・`inspect`・`analyze_symbol` が metadata 参照を compile-time 依存として保持し、C# の attribute サフィックス命名規約を正規化 (#293 追加対応)** — `cdidx deps` の実装である `DbReader.GetFileDependencies` と、`cdidx impact` / MCP `impact_analysis` の heuristic file-hint フォールバックである `DbReader.GetFileDependencyHintsToResolvedType` は、logical reference CTE に metadata 参照も含める。attribute クラスを rename / 削除すれば `[JsonConverterAttribute]` のような注釈サイトも compile-time で壊れるため、ファイル単位の blast-radius 分析はそれらも本物の依存 edge として表示する必要があり、`logical_reference_kind` では `attribute` / `annotation` として正しくラベル付けされる(分類修正前は同じサイトが phantom `call` として漏れていた)。さらに `GetFileDependencies` は C# の attribute 命名規約を正規化する: 慣用的な `[Foo]` は `symbol_name = "Foo"` で保存されるが、定義クラスは `FooAttribute` 命名となるため、logical-reference CTE に C# `attribute` 種別の行をサフィックス `'Attribute'` を付与した別名コピーとして UNION ALL で追加する(元の symbol_name が既に `Attribute` で終わっている場合は重複しないようスキップ)。これにより target ファイル join が attribute クラス側のファイルへ到達する。`GetFileDependencyHintsToResolvedType` はさらに candidate ごとに `has_metadata_ref` フラグを集約し、寄与する参照が metadata だけである edge については `SourceFileHasStructuredTypeEvidence` guard をバイパスするため、`[MyAudit] class Svc` のような純 attribute consumer ファイルも `impact MyAuditAttribute` で出るようになった(以前は filter で落ちていた)。`ResolveImpactFallbackNames` も対称的に、`Attribute` で終わる C# クラス名にはサフィックスを外した別名を追加するため、BFS を使わない file-hint 経路でも source symbol_name が定義クラス名と一致しない `[Foo]` サイトを発見できる。同じ正規化をシンボル単位の参照 reader にも伝播する: `references`・`inspect`・MCP `analyze_symbol` を支える `DbReader.SearchReferences`・`CountSearchReferences`・`CountSearchReferencesTotal` は、新しいヘルパー `ComputeCSharpAttributeSuffixAlias` で C# 限定の `queryAttributeAlias` を計算し、exact(folded と NOCASE)・substring の両方の WHERE 句に OR で追加する。これにより `references MyAuditAttribute`(および `inspect MyAuditAttribute` / `analyze_symbol MyAuditAttribute`)でも慣用的な `[MyAudit]` 参照サイトが `deps` / `impact` と一貫して surface する。substring モードでは alias を二つ目の LIKE にせず exact-OR で束ねているため、ユーザーが `MyAuditAttribute` と打った時に `FooAuditLog` のような無関係な名前に over-match しない。alias は C# 限定なので、Java / Kotlin / Scala / TypeScript の annotation サイトには影響しない。`callers` / `callees` は共有の call-graph 種別 (`call` / `instantiate` / `subscribe`) に絞って `--kind attribute|annotation` を CLI / MCP 境界で拒否する契約なので、alias は意図的に適用しない(dynamic call graph と dependency graph は別契約)。さらに alias 節が誤一致を生まないようスコープする: `ComputeCSharpAttributeSuffixAlias` は `referenceKind` が `attribute` 以外の call-graph 種別のときは `null` を返す(`references FooAttribute --kind call` は厳格一致のままで `Foo()` に一致しない)、3 つの reader メソッドの SQL alias 節は `f.lang = 'csharp' AND r.reference_kind = 'attribute'` に限定するため、`--lang` / `--kind` 無指定の `references FooAttribute` でも Java の annotation や C# の call 行を拾わない。suffix 検出も `OrdinalIgnoreCase` にして case-insensitive 化したため、`references myauditattribute` / `inspect MyAuditATTRIBUTE` のような小文字・混在ケースでも alias `MyAudit` を生成でき、周辺の NOCASE / folded 契約と整合する。回帰テスト `DbReaderTests.GetFileDependencies_IncludesMetadataReferencesAsCompileTimeDependencies`(runtime と metadata の両方の edge が出ることを固定)、`DbReaderTests.GetFileDependencies_MatchesCSharpAttributeSuffixConvention`(`deps` 経由での `[MyAudit]` → `MyAuditAttribute` 正規化)、`DbReaderTests.SearchReferences_MatchesCSharpAttributeSuffixConvention_Substring` / `_Exact` / `_CSharpAttributeSuffixAliasDoesNotBleedToOtherLanguages`(`references` / `inspect` / `analyze_symbol` での同一正規化と C# 限定スコープ)、`DbReaderTests.SearchReferences_CSharpAttributeSuffixAlias_NotAppliedToCallKind`(`--kind call` で alias が発火しないこと)、`DbReaderTests.SearchReferences_CSharpAttributeSuffixAlias_UnscopedLangStillLimitsToCSharpAttributeRows`(無指定でも Java annotation や C# call 行に一致しないこと)、`DbReaderTests.SearchReferences_CSharpAttributeSuffixAlias_CaseInsensitiveQuery`(小文字・混在ケースのクエリでも alias を生成)を追加。対象: `src/CodeIndex/Database/DbReader.cs`、`tests/CodeIndex.Tests/DbReaderTests.cs`。 +- **`callers` / `callees` が CLI と MCP 境界で `--kind attribute|annotation` を拒否し、Swift / Gradle の `@` metadata を `annotation` に再分類 (#293 追加対応)** — metadata 行 (`attribute` / `annotation`) は注釈対象そのものではなく body-range の外側シンボルに帰属するため、`callers Obsolete --kind attribute` は `[Obsolete] void M()` を `M` ではなく外側クラスの下に黙って返し、`[assembly: CLSCompliant]` や Kotlin の `@file:JvmName` のような file-level target は `container_name = NULL` で完全脱落していた。`QueryCommandRunner.RunCallers` / `RunCallees` は `--kind attribute` / `--kind annotation` を `CommandExitCodes.UsageError` で弾き、MCP の `callers` / `callees` ツールは `isError` ツールレスポンスを返すようにした。どちらの経路も、metadata 列挙の正しいパスである `references --kind attribute|annotation` へ誘導する。あわせて `AnnotationLanguages` に `swift` と `gradle` を追加し、Swift の `@available(iOS 13.0, *)` / `@objc` / `@MainActor` や Gradle / Groovy の `@CompileStatic` / `@TaskAction` を `call` ではなく `annotation` として記録するようにした(修正前は引数付きの `@Name(args)` 形は `callers` / `callees` / `hotspots` / `impact` に phantom call edge として漏れ、引数なしの `@objc` / `@MainActor` / `@CompileStatic` / `@TaskAction` 形はこの集合で gate されていた no-arg annotation 出力から外れてインデックス自体に残らなかった)。回帰テストとして `QueryCommandRunnerTests.RunCallersCallees_RejectMetadataKind_WithUsageError`(theory 4 ケース)、`McpServerTests.ToolsCall_CallersOrCallees_MetadataKindReturnsToolError`(theory 4 ケース)、`ReferenceExtractorTests.Extract_SwiftAttributeWithArgs_ClassifiedAsAnnotation`、`ReferenceExtractorTests.Extract_GradleAnnotation_ClassifiedAsAnnotation` を追加。対象: `src/CodeIndex/Cli/QueryCommandRunner.cs`、`src/CodeIndex/Mcp/McpToolHandlers.cs`、`src/CodeIndex/Indexer/ReferenceExtractor.cs`、`tests/CodeIndex.Tests/QueryCommandRunnerTests.cs`、`tests/CodeIndex.Tests/McpServerTests.cs`、`tests/CodeIndex.Tests/ReferenceExtractorTests.cs`。 +- **`[\n Serializable\n]` のような複数行にまたがる引数なし C# 属性セクションを、属性引数内の識別子を誤って取り込まずに索引化 (#293 追加対応)** — 引数なし C# 属性の正規表現は、これまで `[` / `,` を左端、`]` / `,` を右端として同一行内でアンカーしていたため、行頭 `[` と行頭 `]` の間に書かれた ` Serializable` のような裸識別子が `references --kind attribute` から黙って脱落していた。左側は単語境界 lookbehind、右側は行末(`$`)も許容するように緩和した。一方で `[\n JsonConverter(\n ConverterStrategy.AllowNumbers\n )\n]` のように属性の引数リスト内で行末に現れる enum / 修飾定数識別子(`AllowNumbers` など)が no-arg 属性として誤分類される回帰を避けるため、`BuildCSharpAttributeRanges` は `(` / `)` で属性セクションを分割した paren 深さ 0 の「top-level ゾーン」テーブルも並列で出力するようにし、no-arg 属性の採用位置はそのテーブルに限定した(既存の引数付き metadata 分類パスは引き続き属性セクション全体のレンジを使う)。paren 深さは section-local に追跡する(各 `[` がその瞬間のグローバル paren 深さを snapshot として保持)ため、`void M([FromServices] IService s)` のようなパラメータ属性、`delegate void D([Attr] int x)` のようなデリゲートパラメータ属性、`Func f = ([Attr] int x) => x` のようなラムダパラメータ属性のように、`[` が開いた時点で既にグローバル paren 深さが 0 より大きいケースでも、属性リストの top-level を正しく認識できる。`SymbolExtractor` 側でも `BuildCSharpMatchLines` に `StripMultiLineCSharpAttributeInterior` を追加し、複数行にまたがる `[...]` セクションの深さを跨行で追跡して内部行を宣言 regex 適用前に空白化するようにした。これにより ` Serializable` がトップレベル `function` シンボルとして誤抽出され、`definitionNames` ガード経由で参照分類が詰まってしまうのを防ぐ。さらに、複数行の空白化起動条件を「同一行内で閉じない `[` があるかどうか」に広げ、行頭の `[` だけでなく `void M([\n FromServices\n] IService s)` のようなパラメータ属性、`class Bar<[\n TypeParamAttr\n] T>` のような型パラメータ属性、`delegate void D([\n DelegateParamAttr\n] int x)` のようなデリゲート / ラムダのパラメータ属性でも内部行が正しく空白化されるようにした — 以前は開口行で `[` が行頭にある場合しかカバーされていなかったため、上記の形の内部識別子が `function` シンボルとして誤抽出され、`references --kind attribute` から属性参照が黙って脱落していた。同時に空白化ロジックを「属性位置」判定付きに変更し、直前の非空白文字が語文字(`[_A-Za-z0-9]`)や `)` / `]` の場合は属性開口と扱わないようにした。これにより `public int this[\n int i\n] => _items[i];` のような複数行インデクサ宣言(`[` 直前が `this` キーワード)が誤って空白化されなくなり、インデクサが `symbols` / `definition` / `outline` / `inspect` / `unused` / `hotspots` で `function`(正準名 `Item`)として正しく現れる。`[\n Serializable\n]`・`[\n global::System.Obsolete\n]`・`[Required,\n Key]`・`[\n JsonConverter(\n ConverterStrategy.AllowNumbers\n )\n]`(`AllowNumbers` / `ConverterStrategy` が `attribute` として取り込まれないことを固定)に加え、上記の行頭でない `[` で開く複数行パラメータ / 型パラメータ / デリゲートパラメータ属性のケースと複数行インデクサ宣言 (`SymbolExtractorTests.Extract_CSharp_DetectsMultiLineIndexer`) も `SymbolExtractor.Extract` + `ReferenceExtractor.Extract` で end-to-end に回して SymbolExtractor 側の回帰も固定する回帰テストを追加。対象: `src/CodeIndex/Indexer/ReferenceExtractor.cs`、`src/CodeIndex/Indexer/SymbolExtractor.cs`、`tests/CodeIndex.Tests/ReferenceExtractorTests.cs`、`tests/CodeIndex.Tests/SymbolExtractorTests.cs`。 +- **`::` 修飾の C# 引数なし属性と JavaScript の `@Decorator` を正しく分類 (#293 追加対応)** — 引数なし C# 属性の正規表現が修飾子セパレータとして `.` に加えて `::` も受け付けるようになり、`[global::System.Obsolete]` や `[Alias::MyAttr]` のような形が `references --kind attribute` から黙って脱落しなくなった。`AnnotationLanguages` にも `javascript` を追加し、JavaScript はもともと graph 対応言語なので、`@sealed`(引数なし)や `@injectable()`(引数付き)のような JS decorator も `annotation` として再分類され、TypeScript と同じ契約で call-graph に phantom `call` 行を残さない。`[global::System.Obsolete]`・`[Alias::MyAttr]`・JavaScript の `@sealed` / `@injectable()` を押さえる回帰テストを追加。あわせて、以前に進めていた `callers` / `callees` の metadata 契約を README(英日)と MCP ツール説明の両方で引き戻し、metadata 行は注釈対象そのものではなく body-range 上の外側シンボル(クラス直下宣言ならクラス、`[assembly: ...]` のようなファイルレベル target では `container_name` が `null`)に帰属するため `kind: "attribute"` / `kind: "annotation"` を `callers` / `callees` に渡すのは信頼できる metadata 調査経路ではなく、metadata 列挙は `references --kind attribute|annotation` を使うよう案内する文言に修正した。対象: `src/CodeIndex/Indexer/ReferenceExtractor.cs`、`src/CodeIndex/Mcp/McpToolDefinitions.cs`、`tests/CodeIndex.Tests/ReferenceExtractorTests.cs`、`README.md`。 +- **`impact` BFS が C# の event subscribe edge を辿るよう修正 (#293 追加対応)** — `impact` / `impact_analysis` BFS の唯一の呼び出し箇所である `DbReader.GetCallersExact` が、共有 filter `CallGraphReferenceKindsSql = ('call', 'instantiate', 'subscribe')` で参照を絞り込むようになった(以前は `InvokeReferenceKindsSql = ('call', 'instantiate')` を使っていたため、`Changed += OnChanged` のような subscribe 行を静かに捨てていた)。これにより `callers` / `callees` / `hotspots` と同じ call-graph 契約を `impact` も共有し、イベント駆動の transitive caller chain が `impact` でだけ短くなる不整合が解消する。`DbReaderTests.GetTransitiveCallers_FollowsSubscribeEdges` の回帰テストで subscribe edge が BFS 出力に含まれることを固定。Metadata edge (`attribute` / `annotation`) の除外は従来通り。対象: `src/CodeIndex/Database/DbReader.cs`、`tests/CodeIndex.Tests/DbReaderTests.cs`。 +- **C# のパラメータ属性・型パラメータ属性・ラムダ属性・複数行 `[...]` 属性を `attribute` として分類し、引数なし属性とアノテーションも索引化、MCP ツール説明に既定の metadata フィルタを反映 (#293 追加対応)** — `ReferenceExtractor` がファイル全体の C# `[...]` セクションを事前パスでトークン化するようになり、`void M([FromServices] IService s)` のように `[` の直前が宣言境界ではなく `(` / `,` で始まるパラメータ属性、`class C<[Attr("x")] T>` のように `<` で始まる型パラメータ属性、`void M([Attr("x")] (int a, int b) value)` のように `]` の直後が `(` のまま続く tuple 型パラメータ属性、`var f = [Attr("x")] () => 0;` のように `=` の直後にあるラムダ属性、さらに `[\n Foo("x")\n]` や `public void M(\n [Attr("x")]\n int a)` のような複数行形式も、既存の同一行ケースと同じように属性として扱われるようになった。`(` / `,` / `<` / `=` 直後の曖昧さは、`[` から対応する `]` まで前方走査し、`]` の直後の非空白トークンが宣言を開始するかで判定する — 識別子は原則受理するが、式継続キーワード `is` / `as` / `switch` / `with` / `when` は宣言扱いしないので `Consume([Make()] is int[] xs)` / `([Make()] as int[])` / `Consume([Make()] switch { _ => true })` のような collection expression は `call` のまま残り、`@` は verbatim 識別子の先頭として、`(` は tuple 型パラメータとラムダ仮引数リストの先頭として受理し、`[A][B]` のような連続 bracket は内側 `[` に再帰して判定する。さらに `[Serializable]`、`[Obsolete]`、`[System.Obsolete]`、`[assembly: CLSCompliant]`、`[Required, Key]` のような引数なし C# 属性と、`@Deprecated`・`@Override`・`@org.junit.Test`・`@field:Deprecated` のような引数なし Java 系アノテーションは、`(` を要求する既存の `CallRegex` では拾えないため専用の no-arg 正規表現から emit するようになった。C# 側では引き続き `[...]` 事前パスのレンジに含まれるかで gate するので `arr[i]` のような indexer アクセスは属性化されず、アノテーション側は lookbehind `(?` 型パラメータ属性、`void M([Attr("x")] (int a, int b) value)` tuple 型パラメータ属性、`var f = [Attr("x")] () => 0;` ラムダ属性、上記 no-arg C# 属性群、Java `@Deprecated` / `@Override` / `@org.junit.Test` の no-arg アノテーション群、Kotlin `@field:Deprecated` と `return@foo` の negative ケース、indexer アクセスが属性化されないこと、`[\n Foo("x")\n]`、改行を挟んだパラメータ属性、`[return: NotNullWhen(true)]`、防御的に `Consume([Make()])` の collection expression-in-argument、および `[Make()] is ...` / `[Make()] as int[]` / `[Make()] switch { ... }` の 3 つの式継続ケースを押さえる回帰テストを追加。対象: `src/CodeIndex/Indexer/ReferenceExtractor.cs`、`src/CodeIndex/Mcp/McpToolDefinitions.cs`、`tests/CodeIndex.Tests/ReferenceExtractorTests.cs`。 +- **C# 属性と Java/Kotlin/Scala/TypeScript の annotation 使用が phantom な `call` 行として call-graph を汚染しないよう修正 (#293)** — `ReferenceExtractor` が C# の `[...]` 属性リスト内(直接形 `[Attr(args)]`、target 付き `[return: Attr]` / `[assembly: Attr]`、カンマ区切り `[Foo("a"), Bar("b")]` を含む)に現れる識別子を `attribute` 参照、Java 系 `@` マーカーが先行する識別子(`@org.junit.Test(args)` のようなドット修飾チェーン越し、および Kotlin の use-site target `@field:Deprecated("msg")` / `@get:JsonName("x")` / `@file:JvmName("Foo")` 越しを含む)を `annotation` 参照として分類するようになった。属性 `[` の判定には宣言位置ガードを入れ、C# 12 の collection expression(`var xs = [Make(), Make()]`、`Consume([Make(), Make()])`、`return [Make(), Make()]`)や indexer アクセス(`arr[Compute()]`、連続 `arr[Compute()][Compute()]`、`matrix[Row()][Col()]`)の内側の呼び出しは `call` のまま残り、metadata に誤分類されないようにした。`[` の直前が別の `]` だった場合は、対応する `[` まで左方向に(balanced paren を跨いで)巻き戻してその開き bracket 自体が宣言位置かを再判定するため、`[A("x")][B("y")]` のような連続 attribute list は引き続き `attribute` として認識される一方、`arr[i][Compute()]` のような連続 indexer は `call` のまま残る。Kotlin の use-site target 判定も、ドット修飾子チェーンを先に剥がしたうえで `@` または Kotlin の `target:` + `@` を判定する構成に変え、`@field:com.example.Deprecated("msg")` / `@get:com.fasterxml.jackson.annotation.JsonProperty("x")` のような修飾付き注釈名と use-site target の組み合わせにも対応するようにした。共有 filter `CallGraphReferenceKindsSql = ('call', 'instantiate', 'subscribe')` を default-kind の `callers`・`callees`・count クエリ・hotspot CTE・symbol-hotspot グルーピングに適用し、`callers Obsolete`・`callers Deprecated`・`impact Conditional`・`hotspots` で phantom caller edge や metadata のみの参照による件数膨張が発生しないようにした。`references` の既定は全 reference kind を表示して metadata 使用も確認できる状態を保ちつつ、`references --kind attribute` / `--kind annotation`(と MCP ツール)で metadata 行を明示取得できる。CLI help・`--kind` バリデータ・README も新しい reference kind と `callers` / `callees` / `hotspots` / `impact` の新しい既定契約を反映する。`[Obsolete("msg")]`・`[return: NotNull("x")]`・`[Foo("a"), Bar("b")]`・`[AttributeUsage(...)]`、Java `@Deprecated(since="1.0")`・修飾付き `@org.junit.Test(...)`、Kotlin `@Deprecated("msg")`、Kotlin `@field:Deprecated` / `@get:JsonName` / `@file:JvmName` の分類、通常のメソッド本体が引き続き `call` を返すこと、C# collection expression や indexer アクセス内の呼び出しが `call` のままであることを押さえる回帰テストを追加。対象: `src/CodeIndex/Indexer/ReferenceExtractor.cs`、`src/CodeIndex/Database/DbReader.cs`、`src/CodeIndex/Database/DbSymbolReader.cs`、`src/CodeIndex/Cli/QueryCommandRunner.cs`、`src/CodeIndex/Cli/ConsoleUi.cs`、`tests/CodeIndex.Tests/ReferenceExtractorTests.cs`、`tests/CodeIndex.Tests/ConsoleUiTests.cs`、`README.md`、`DEVELOPER_GUIDE.md`。Closes #293。 - **複数行文字列マスクを Python 三重引用・Rust raw・JS/TS テンプレートリテラルまで拡張 (#291)** — 旧来 `StructuralLineMasker` は C# の raw / verbatim / 補間付き文字列本体しかマスクしていなかったため、Python `""" ... """` / `''' ... '''`(`r` / `b` / `u` / `f` とそれらの大文字接頭辞を含む)、Rust `r#"..."#` / `r##"..."##`(`b` / `c` 接頭辞を含む)、JavaScript / TypeScript のテンプレートリテラル本体に置かれたコード風 fixture テキストが、行ベース抽出で phantom なシンボルや phantom `call` 参照エッジとして残ってしまっていた。本修正で masker を言語ごとにディスパッチし、各複数行リテラル本体を regex 判定前に空白化しつつ、JS/TS の `${...}` 補間内容と Python f-string の `{expr}` 補間内容(`{{` / `}}` は literal brace のエスケープとして扱う)を残して hole 内の実呼び出しを参照エッジとして維持し、hole 内のネストした `{` / `}` を深さ付きで追跡し、hole ごとのブレースコンテキスト stack で expression brace(`${({a:1} / 2)}` のような式の括弧)と arrow body のステートメントブロック(`${(() => { if (x) {} /regex/.test(y); })()}`)を区別し、object literal の `}` を hole 終端と誤認しないようにし、hole を閉じる `}` も空白でマスクして周囲の JS/TS brace count(テンプレートリテラルを含む関数本体など)を崩さないようにし、トップレベルの `}` は regex-legal なまま残して `if (x) {} /.../` のような通常ブロック直後の regex literal を division に取り違えて後続の backtick を phantom テンプレートとして吸ってしまう事態を避け、statement-head パレンのコンテキスト(`if` / `while` / `for` / `switch` / `catch` / `with`)を追跡して `if (x) /regex/` やテンプレートホール内の同パターンの `/` を regex literal として扱うようにし(以前は division 扱いで regex 本文の backtick を phantom テンプレートとして読んでしまっていた)、JS/TS の lex state を行をまたいで保持してテンプレートホール内の multi-line 除算継続でも演算子 / regex 判定を正しく引き継ぎ、Rust のネスト可能な `/* */` ブロックコメントに対応してコメントアウトされたコード内の `r"..."` 本体が漏れないようにし、Rust の `'X'` 文字リテラルとエスケープを `'lifetime` 識別子と取り違えないようにし、f-string ホール内のネストした Python 三重引用符文字列は内容を空白でマスクしてインデント依存のボディ判定がブランク行として見えるようにし、さらに外側ホール内のネスト三重引用符 *f*-string(例: `f"""{format(f"""{real_call()}""")}"""`)についても f 接頭辞と内側ホールの brace 深度を別途追跡し、内側の `{expr}` を call edge として残して本体と一緒に空白化しないようにし、さらに外側ホール内のネスト単行 Python f-string についても quote とホール外の本体を空白化して後段 `ReferenceExtractor.PrepareLine` の `StringLiteralRegex` が内部の call edge まで巻き込んで消さないようにし、そのネスト単行 f-string の inner hole 内でも単行文字列リテラルをスキップして、`f"{prefix('}') + real_call()}"` のようにクォート内の `{` / `}` で inner hole が早閉じしないようにし、JS/TS の regex literal をスキップして regex 本体の backtick や `}` が phantom テンプレートを開いたり hole を早期に閉じたりしないようにした。`SymbolExtractor` / `ReferenceExtractor` に各言語向けの回帰テストを追加し、Python f-string の補間、外側ホール内のネスト三重引用・ネスト単行 f-string(クォート内に `}` を含む inner hole のケースを含む)、ブロックコメント内の Rust raw string、JS regex literal、JS テンプレートホールの複数行除算継続、JS の内側 object close 後の除算判定、JS トップレベルの block-close 直後の regex literal、`if (x) /regex/` をトップレベルおよびテンプレートホール内 arrow body で使うケース、テンプレートホール内 arrow body の block-close 直後 regex literal のケース、およびテンプレートホール内の postfix `++` / `--` 直後の `/` のケース(以前は 2 文字演算子の 2 文字目が一般トークンに落ちて続く `/` が phantom regex を開始し、除算の被除数とその後の call edge を吸い込んでいた)、およびテンプレートホール内の `class Foo {}` / `class Foo extends Bar {}` / 匿名 `class {}` を(object literal の expression brace ではなく)statement block として開かせ、対応する `}` を regex-legal に保って直後の `/regex/` を division に倒さず、regex 本文中の backtick を phantom template として読まないようにしたケース、およびテンプレートホール内の `switch (x) { case N: {} /regex/.test(x); }` と optional-binding `try {} catch {} /regex/.test(x);` についても、`case N:` / `default:` の直後の `{` を object literal の expression brace ではなく statement block として開かせる one-shot フラグ `CaseColonBlockPending` を追加し、case ラベル終端の `:` の判定を「paren 深さ 0」ではなく「`case` / `default` 時点に記録した paren 深さ」との一致で行うことで、テンプレートホール全体が `(() => { ... })()` 等でラップされ paren 深さが 0 にならないケース(`${(() => { switch (v) { case 1: {} /regex/.test(v); })()}`)でも case label を正しく検出できるようにし、`catch` を `else` / `do` / `try` / `finally` と同様の block 開始キーワード扱いにして ES2019 の optional-binding `catch {}` を object literal の expression brace と誤認しないようにし、直後の `/regex/` を regex-legal に保つケース、およびテンプレートリテラル引数 `if (\`${x}\`) /\`/.test(x); realCall();` とそのホール内ネスト版 ``${(() => { if (`${x}`) /`/.test(x); runTask(); })()}`` についても、`JsTemplateLiteralFrame` の push 時に外側の `JsLexState`(paren stack / class-header hint / case-label hint / 直前トークン種)を退避し、閉じ backtick で復元するようにして、テンプレート本体の reset で `if` / `while` / `for` / `switch` / `catch` / `with` 由来の statement-head `(` コンテキストが失われないようにし、テンプレート直後の `)` が `StatementHeadCloseParen` のまま残って続く `/` が regex literal として認識され、regex 本文の backtick が phantom テンプレート開始として誤認されて後続の `realCall()` / `runTask()` エッジを飲み込まないようにしたケースまで固定したうえで、さらに Rust のネスト `/* /* ... */ ... */` ブロックコメントと、JS/TS テンプレートホール内のブロックコメント(例: ``${/* fake(); */ realCall()}``)については、コメント本体とその `/*` / `*/` 区切りを一緒に空白化することで、ネスト Rust コメントの外側閉じをネスト非対応の下流コメントストリッパが巻き込んでも内部の識別子が phantom call 参照として漏れないようにし、ホール内で verbatim に保持される複数行ブロックコメント本文の識別子もリークしないようにしたうえで、コメント直後の本物の呼び出しは引き続き囲みコンテナに正しく帰属することを確認し、さらに Python のネスト三重 f-string の *内側* ホール内に triple-quoted 文字列が現れるケース(`f"""{format(f"""{len('''\n}\n''') + real_call()}""")}"""`)も、内側ホール走査側で `'''` / `"""` 開始を先に検出して閉じ三重までを行をまたいで追跡するようにした(`SkipPythonSingleLineString` は同一行の対しか見ないため、以前は最初の 2 つの `'` だけ消費して 3 つ目を取り残し、翌行の `}` が内側ホール閉じと誤認され、三重閉じ直後の `real_call()` エッジが消えていた)ケースも固定し、さらにテンプレートホール内の TypeScript 宣言 `enum Local {}` / `interface Local {}` / `namespace Local {}` / `module Local {}` も、従来 `class` だけが対象だった declaration-body の statement block 分類に加え、対応する `}` 後の `/regex/` を regex-legal に保つことで、直後の `/regex/` が division に倒れ regex 本文の backtick が phantom template 開始として読まれて ``${(() => { enum Local { A } /`/.test(value); runTask(); })()}`` 後の `runTask()` などが消える退行を防いだケースも固定したうえで、さらに Python のネスト *単行* f-string(外側 `f"""` → hole → ネスト単行 `f""`)の *内側* `{expr}` hole に複数行の `'''...'''` 三重が入るケース(`f"""{format(f"{len('''\n}\n''') + real_call()}")}"""`)についても、旧 `MaskNestedPythonFString` は行ローカル helper のままだったため、内側 hole や内側三重が開いたまま行末に達すると状態が失われ、翌行の `}` が外側 hole を早閉じして三重閉じ直後の `real_call()` が外側 f-string 本体として飲み込まれていたのを、ネスト単行 f-string の quote・内側 hole 深度・内側 hole 内で開かれた三重引用符文字列の状態を外側三重自身の状態と並行して行をまたいで保持するように改め、三重閉じ後の実呼び出しが参照グラフから消えないよう固定したうえで、phantom シンボル / 参照が発生しない一方で補間 hole 内の実呼び出しが正しい container 下の参照として残ることを CLI 統合テストでも検証した。対象: `src/CodeIndex/Indexer/StructuralLineMasker.cs`、`tests/CodeIndex.Tests/SymbolExtractorTests.cs`、`tests/CodeIndex.Tests/ReferenceExtractorTests.cs`、`tests/CodeIndex.Tests/QueryCommandRunnerTests.cs`。Closes #291。 - **ラップされた C# コンストラクタ初期化子 `: base(...)` / `: this(...)` が phantom `function base` / `function this` を生まないよう修正 (#331)** — 共有の C# `CSharpTypePattern` の文字クラスには意図的に `:` を含めており、`Alias::Type IFoo.Create()` のような alias-qualified な戻り値型を明示的インターフェース実装の経路で取り続けるために必要な設計になっている。その結果、Allman スタイルで ` : base(s, 0)` や ` : this(a)` のようにラップされたコンストラクタ初期化子が、`returnType=":" + name="base"` として method regex に引っかかる危険が残り、既存の先頭文字ガード `(?![?:])` を将来調整した際に phantom `function base` / `function this` として再発する余地があった。method regex の `name` キャプチャ直前に `(?!(?:base|this)\b)` の negative lookahead を追加し、先頭文字ガードが緩んだ場合でも phantom が漏れないよう二重化した。インデクサ専用パターンは引き続き `this[...]` を name として拾う。ラップされた `: base(...)` / `: this(...)` の両形と、同一行形・式本体形を押さえ、fixture の 5 本のコンストラクタが全て索引され、`function base` / `function this` が出ないことを固定する `SymbolExtractor` 回帰テストも追加した。対象: `src/CodeIndex/Indexer/SymbolExtractor.cs`、`tests/CodeIndex.Tests/SymbolExtractorTests.cs`。Closes #331。 - **Java のタブインデント `enum` メンバーを誤検出なく索引 (#364, #292)** — `SymbolExtractor` の C# enum メンバー正規表現は `#214` で既に `^\s{2,}` から `^\s+` に緩和済みで、`\tRed,` のようなタブインデント C# メンバーも抽出され、`Extract_CSharp_DetectsTabIndentedEnumMembers` 回帰テストがその挙動を固定している。一方、対になる Java の enum メンバー正規表現は依然として `^\s{2,}` を要求しており、EditorConfig の `indent_style=tab` や昔ながらの IDE 既定のように 1 タブだけでインデントしている Java コードでは全 enum メンバーが黙って脱落していた(enum 本体は `Color` として拾われるが、`RED` / `GREEN` / `BLUE` は `symbols` / `definition` / `references` / `callers` / `callees` から不可視になっていた)。しかし単純な `^\s+` 緩和は、クラス本体の `\tRED();` のようなタブインデントのメソッド呼び出しも phantom な enum メンバーとして拾ってしまい #292 を悪化させる。そこで Java の enum メンバー正規表現を廃止し、既存の C# 実装にならった body-scoped scanner `ExtractJavaEnumMembers` に置き換えた。Scanner は enum 本体を走査しつつ、文字列・char literal・行/block コメント・Java 15+ text block・丸括弧・角括弧・ネストした中括弧(匿名メンバー本体用)を追跡し、top-level `,` ごとにメンバーを emit、最初の top-level `;` で停止する。メンバー名抽出は先頭の `@Annotation(...)` を lex-aware に読み飛ばすため、引用符付き丸括弧 / block コメント / text block を含むアノテーションでも束縛が崩れず、`@Deprecated A(1)` も `A` として束縛される。enum 本体の範囲も新設の `FindJavaBraceRange` で同じ lexer 状態機械を使って解決するため、text block や文字列リテラル内の `}` で本体範囲が早期終了してリテラル以降のメンバーが落ちる問題も起こらない。primary scanner が丸括弧 / 角括弧の深さを 0 に戻せずに終了した場合(部分編集中の `@Ann(` のような未閉鎖アノテーション)は、bounded な line-regex recovery pass を走らせ、明白な大文字始まり識別子のメンバーを救済する。recovery は本体の中括弧深さを追跡し、匿名メンバー本体やメソッド本体内の行を phantom メンバーとして拾わないようにし、メンバー名基準で重複排除し(primary scanner はアノテーション行に `StartLine` を刻み、recovery はメンバー名行に刻むため、StartLine 基準の重複排除では二重 emit が避けられない)、top-level `;` で停止する。メンバー名正規表現は JLS §3.8 に従い Unicode 識別子を受け付けるため、`RÉSUMÉ` や `NAÏVE` のようなメンバーも最初の非 ASCII 文字で切断されずそのまま抽出される。回帰テストを追加: `Extract_Java_DoesNotExtractMethodCallsAsEnumMembers` はクラス本体の `\tRED();` / `\tGREEN();` がシンボル化されないこと、`Extract_Java_StopsEnumMembersAtSemicolon` は top-level `;` 以降の宣言が enum メンバーに昇格しないこと、`Extract_Java_HandlesAnnotationWithQuotedParen` / `Extract_Java_HandlesBlockCommentBetweenAnnotationAndMember` は lex-aware なアノテーション skip、`Extract_Java_RecoversMembersWhenAnnotationIsMalformed` / `Extract_Java_HandlesEmptyEnumBody` / `Extract_Java_HandlesEnumWithOnlySemicolon` / `Extract_Java_HandlesTrailingComma` / `Extract_Java_HandlesAnonymousMemberBody` は bounded recovery と本体形状のエッジケース、`Extract_Java_RecoveryIgnoresLinesInsideAnonymousMemberBody` / `Extract_Java_RecoveryDedupsByNameAcrossAnnotationStartLines` は brace-depth-aware recovery と名前基準重複排除、`Extract_Java_DetectsUnicodeEnumMembers` は Unicode メンバー名、`Extract_Java_HandlesTextBlockContainingBrace` / `Extract_Java_HandlesStringContainingBrace` は lex-aware な本体範囲解決を固定する。既存のタブ / 2-space / static-final / 宣言カバレッジは維持。対象: `src/CodeIndex/Indexer/SymbolExtractor.cs`、`tests/CodeIndex.Tests/SymbolExtractorTests.cs`、`DEVELOPER_GUIDE.md`。Closes #364、#292。 diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 779bb94444..08db041f46 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -130,7 +130,7 @@ symbol_references ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, symbol_name TEXT, -- referenced symbol name - reference_kind TEXT, -- "call", "instantiate", "subscribe", "type_reference" + reference_kind TEXT, -- "call", "instantiate", "subscribe", "attribute", "annotation", "type_reference" line INTEGER, -- 1-based line number column_number INTEGER, -- 1-based column number context TEXT, -- trimmed source line @@ -1151,7 +1151,7 @@ symbol_references ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, symbol_name TEXT, -- 参照先シンボル名 - reference_kind TEXT, -- "call", "instantiate", "subscribe", "type_reference" + reference_kind TEXT, -- "call", "instantiate", "subscribe", "attribute", "annotation", "type_reference" line INTEGER, -- 1始まりの行番号 column_number INTEGER, -- 1始まりの列番号 context TEXT, -- trim済みソース行 diff --git a/README.md b/README.md index 3516ce9c33..b46b899e16 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,7 @@ cdidx map --path src/ --exclude-tests --json | `--exact` | `search`, `find`, `symbols`, `definition`, `references`, `callers`, `callees`, `inspect` | Backward-compatible shorthand. Prefer `--exact-substring` for `search`, keep `--exact` for `find`, and prefer `--exact-name` for symbol / graph commands plus `inspect`. CLI JSON and MCP `structuredContent` expose `exact_index_available` / `degraded_reason`; MCP also keeps the legacy camelCase aliases `exactIndexAvailable` / `degradedReason` for backward compatibility. | | `--exact-substring` | `search` | Preferred explicit name for search exactness: case-sensitive exact substring (FTS5 bypassed). | | `--exact-name` | `symbols`, `definition`, `references`, `callers`, `callees`, `inspect` | Preferred explicit name for symbol-name exactness: NFKC + Unicode CaseFold exact equality (`Ä` / `ä`, `Run` / `Run`, ligatures, sharp-S, and Greek final sigma collapse). Unicode CaseFold remains locale-invariant, so Turkish dotted `İ` is still distinct from plain `i`. For C#, pass the canonical extracted name (`operator +`, `operator checked +`, `explicit operator Money`, `implicit operator decimal`, `Item`) rather than source keywords like `this` / `explicit`. Falls back to ASCII `COLLATE NOCASE` while the DB still contains stale fold metadata; prefer `cdidx backfill-fold`, or use a plain `cdidx index .` if it rewrites or purges every stale row, otherwise `--rebuild`. `status --json` exposes `fold_ready` and `csharp_symbol_name_ready` so AI clients can tell which path is active. When a read-only legacy DB is missing the fallback exact-match indexes, human-readable output warns and CLI JSON / MCP `structuredContent` expose degraded-state metadata. | -| `--kind ` | `definition`, `references`, `callers`, `callees`, `symbols`, `hotspots`, `unused`, `validate` | Filter by kind (case-insensitive; `--kind FUNCTION` is treated as `--kind function`). `definition` / `symbols` / `hotspots` / `unused` use symbol kinds (`function`, `class`, `struct`, `interface`, `enum`, `property`, `event`, `delegate`, `namespace`, `import`); `references` / `callers` / `callees` use reference kinds (`call`, `instantiate`, `subscribe`), and omitting `--kind` keeps all indexed reference kinds visible while collapsing identical constructor `call` + `instantiate` rows at one physical site; `validate` uses issue kinds such as `bom` | +| `--kind ` | `definition`, `references`, `callers`, `callees`, `symbols`, `hotspots`, `unused`, `validate` | Filter by kind (case-insensitive; `--kind FUNCTION` is treated as `--kind function`). `definition` / `symbols` / `hotspots` / `unused` use symbol kinds (`function`, `class`, `struct`, `interface`, `enum`, `property`, `event`, `delegate`, `namespace`, `import`); `references` accepts all indexed reference kinds (`call`, `instantiate`, `subscribe`, `attribute`, `annotation`, `type_reference`); `callers` / `callees` accept only the call-graph kinds (`call`, `instantiate`, `subscribe`) and reject `--kind attribute` / `--kind annotation` with a usage error (the metadata row is attributed to the enclosing body-range symbol rather than the annotated target, so `callers` / `callees` are not a reliable path for metadata — use `references --kind attribute` / `references --kind annotation` for metadata enumeration). `references` defaults to every indexed reference kind so metadata usages remain visible, while `callers` / `callees` / `hotspots` / `impact` default to the call-graph kinds only (`call`, `instantiate`, `subscribe`) and exclude metadata edges (`attribute`, `annotation`, `type_reference`). Identical constructor `call` + `instantiate` rows at one physical site still collapse; `validate` uses issue kinds such as `bom` | | `--body` | `definition`, `inspect` | Include reconstructed body content when the language extractor can infer the body range | | `--count` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `impact`, `unused`, `hotspots` | Return only counts. `search` / `definition` / `references` / `callers` / `callees` / `symbols` / `files` / `find` / `unused` ignore `--limit` and return authoritative totals; `impact` and `hotspots` still report the visible page count and may truncate with `--limit` (with `--json`: a single count object; commands that expose file counts add `files`) | | `--group-by-name` | `hotspots` | Collapse rows that share the same `(name, kind)` across files into one representative result while preserving `definition_sites` / `paths` metadata in JSON | @@ -814,8 +814,8 @@ Once configured, the AI can directly call these tools: | `search` | Full-text search across code chunks | | `definition` | Reconstruct a symbol declaration and optional body | | `references` | Find indexed references for supported languages; identical constructor `call` + `instantiate` rows collapse by default | -| `callers` | List callers for a named symbol in supported languages; `kind` filters by reference kind, and the default keeps invocation-like kinds (`call`, `instantiate`, `subscribe`) visible, hides compile-time `type_reference` rows (e.g. `nameof(X)` / `typeof(T)`), and collapses identical constructor `call` + `instantiate` rows at one physical site | -| `callees` | List callees for a named symbol in supported languages; the default keeps invocation-like kinds (`call`, `instantiate`, `subscribe`) visible, hides compile-time `type_reference` rows, and collapses identical constructor `call` + `instantiate` rows at one physical site | +| `callers` | List callers for a named symbol in supported languages; `kind` filters by reference kind. The default keeps invocation-like kinds visible (`call`, `instantiate`, `subscribe`) while hiding metadata edges (`attribute`, `annotation`) and compile-time `type_reference` rows (e.g. `nameof(X)` / `typeof(T)`). `callers` is not a reliable path to metadata — an attribute / annotation row is attributed to the enclosing body-range symbol (the class for a member declaration) or drops entirely when the target is file-level (`[assembly: ...]`, where `container_name` is `null`). Use `references` with `kind: "attribute"` / `kind: "annotation"` for metadata enumeration. Identical constructor `call` + `instantiate` rows at one physical site collapse. | +| `callees` | List callees for a named symbol in supported languages; the default keeps invocation-like kinds visible (`call`, `instantiate`, `subscribe`) while hiding metadata edges (`attribute`, `annotation`) and compile-time `type_reference` rows. Identical constructor `call` + `instantiate` rows at one physical site collapse. | | `symbols` | Find functions, classes, interfaces, imports, and namespaces by name | | `files` | List indexed files | | `find_in_file` | Find literal substring matches inside known indexed files with line/column context | @@ -1309,7 +1309,7 @@ cdidx map --path src/ --exclude-tests --json | `--exact-substring` | `search` | `search` 用の推奨 explicit alias。大文字小文字を区別する完全部分一致(FTS5 バイパス)。 | | `--exact-name` | `symbols`, `definition`, `references`, `callers`, `callees`, `inspect` | symbol-name exactness 用の推奨 explicit alias。NFKC + Unicode CaseFold による完全一致(`Ä` / `ä`、全角 `Run` / `Run`、合字、sharp-S、Greek final sigma を畳み込む)。Unicode CaseFold は locale-invariant のため、トルコ語の dotted `İ` は plain `i` と同一視しない。C# では `this` / `explicit` のような source keyword ではなく、抽出済みの canonical name(`operator +`、`operator checked +`、`explicit operator Money`、`implicit operator decimal`、`Item`)を渡す。DB に stale な fold metadata が残る間は ASCII `COLLATE NOCASE` に fallback するため、まず `cdidx backfill-fold`、または stale row を全置換できる通常の `cdidx index .`、それが無理なら `--rebuild` を使う(`status --json` の `fold_ready` と `csharp_symbol_name_ready` で判定)。read-only な旧DBに fallback exact-match index が無い場合は、人間向け出力が WARN を表示し、CLI JSON と MCP `structuredContent` が縮退メタデータを返す。 | | `--lang ` | クエリ系 | 言語でフィルタ(大文字小文字を区別しない。`--lang Python` は `--lang python` と同じ扱い)。未知の値を指定すると、人間向け出力の 0 件応答に `Available: <言語一覧>` ヒントが付く。 | -| `--kind ` | `definition`, `references`, `callers`, `callees`, `symbols`, `hotspots`, `unused`, `validate` | 種別でフィルタ(大文字小文字を区別しない。`--kind FUNCTION` は `--kind function` と同じ扱い)。`definition` / `symbols` / `hotspots` / `unused` は symbol kind(`function`、`class`、`struct`、`interface`、`enum`、`property`、`event`、`delegate`、`namespace`、`import`)、`references` / `callers` / `callees` は reference kind(`call`、`instantiate`、`subscribe`)を使い、`--kind` 未指定時は全 reference kind を表示したまま同じ物理位置にある constructor の `call` + `instantiate` 重複行を集約する。`validate` は `bom` などの issue kind を使う | +| `--kind ` | `definition`, `references`, `callers`, `callees`, `symbols`, `hotspots`, `unused`, `validate` | 種別でフィルタ(大文字小文字を区別しない。`--kind FUNCTION` は `--kind function` と同じ扱い)。`definition` / `symbols` / `hotspots` / `unused` は symbol kind(`function`、`class`、`struct`、`interface`、`enum`、`property`、`event`、`delegate`、`namespace`、`import`)、`references` は全ての reference kind(`call`、`instantiate`、`subscribe`、`attribute`、`annotation`、`type_reference`)を受け付ける。`callers` / `callees` は call-graph 種別のみ(`call`、`instantiate`、`subscribe`)を受け付け、`--kind attribute` / `--kind annotation` は usage error で拒否する(metadata 行は注釈対象そのものではなく body-range 上の外側シンボルに帰属するため `callers` / `callees` は metadata 列挙の信頼できる経路ではない — metadata 列挙は `references --kind attribute` / `references --kind annotation` を使う)。`references` の既定は全 reference kind を表示して metadata 参照も見えるままにするが、`callers` / `callees` / `hotspots` / `impact` の既定は call-graph kind(`call`、`instantiate`、`subscribe`)のみで、`attribute` / `annotation` / `type_reference` のような metadata edge は除外する。同じ物理位置にある constructor の `call` + `instantiate` 重複行は引き続き集約する。`validate` は `bom` などの issue kind を使う | | `--body` | `definition`, `inspect` | 言語抽出器が本体範囲を推論できる場合に本体内容も含める | | `--count` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `impact`, `unused`, `hotspots` | 件数だけを返す。`search` / `definition` / `references` / `callers` / `callees` / `symbols` / `files` / `find` / `unused` は `--limit` を無視した総件数を返し、`impact` と `hotspots` は visible page count のままで `--limit` によって切り詰められることがある(`--json` 併用時は単一の count オブジェクト。files 件数を出すコマンドは `files` も返す) | | `--group-by-name` | `hotspots` | ファイルをまたいで同じ `(name, kind)` を共有する行を代表1件に集約し、JSON では `definition_sites` / `paths` metadata を保持したまま返す | @@ -1689,8 +1689,8 @@ OpenAI Codex CLI (`codex.json` または `~/.codex/config.json`): | `search` | コードチャンクの全文検索 | | `definition` | シンボルの宣言と必要なら本体を再構成して取得 | | `references` | 対応言語でインデックス済み参照を検索。constructor site の `call` + `instantiate` 重複は既定で集約 | -| `callers` | 対応言語で指定シンボルの caller を列挙。`kind` は reference kind を指し、未指定時は invocation 系の kind(`call` / `instantiate` / `subscribe`)を表示しつつコンパイル時の `type_reference`(`nameof(X)` / `typeof(T)` 等)を既定で非表示にし、同じ物理位置にある constructor の `call` + `instantiate` 重複を集約 | -| `callees` | 対応言語で指定シンボルの callee を列挙。未指定時は invocation 系の kind(`call` / `instantiate` / `subscribe`)を表示しつつコンパイル時の `type_reference` を既定で非表示にし、同じ物理位置にある constructor の `call` + `instantiate` 重複を集約 | +| `callers` | 対応言語で指定シンボルの caller を列挙。`kind` は reference kind を指し、既定では invocation 系の kind(`call`、`instantiate`、`subscribe`)のみを表示して `attribute` / `annotation` のような metadata edge とコンパイル時の `type_reference`(`nameof(X)` / `typeof(T)` 等)は除外する。metadata 行の container は注釈対象そのものではなく body-range 上の外側シンボル(メンバ宣言ならクラス)に設定され、`[assembly: ...]` のようなファイルレベル target では `container_name` が `null` になって `callers` 結果から脱落する。C# の `[...]` 属性や Java 系 `@Annotation(...)` を列挙したいときは `references --kind attribute|annotation` / MCP `references` を使う。同じ物理位置にある constructor の `call` + `instantiate` 重複は集約する。 | +| `callees` | 対応言語で指定シンボルの callee を列挙。既定は invocation 系の kind(`call`、`instantiate`、`subscribe`)のみで、`attribute` / `annotation` のような metadata edge とコンパイル時の `type_reference` は除外する。同じ物理位置にある constructor の `call` + `instantiate` 重複は集約する。 | | `symbols` | 関数・クラス・インターフェース・import・namespace を名前で検索 | | `files` | インデックス済みファイル一覧 | | `find_in_file` | 既知のインデックス済みファイル内でリテラル部分文字列一致を行・列付きで検索 | diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index b45c9a6c7e..0cb7182bbb 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -388,7 +388,7 @@ public static void PrintUsage(bool showBanner = true) Console.WriteLine(" --exact Backward-compatible shorthand. Prefer --exact-substring for search, keep --exact for find, and prefer --exact-name for symbols/definition/references/callers/callees/inspect."); Console.WriteLine(" --exact-substring Search only: case-sensitive exact substring (no FTS5)"); Console.WriteLine(" --exact-name symbols/definition/references/callers/callees/inspect: NFKC + Unicode CaseFold exact name match (legacy/stale-fold DBs fall back to ASCII NOCASE; use `cdidx backfill-fold` or check `status --json` fold_ready)"); - Console.WriteLine(" --kind definition/symbols/hotspots/unused: symbol kind; references/callers/callees: reference kind (call/instantiate/subscribe); validate: issue kind"); + Console.WriteLine(" --kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind"); Console.WriteLine(" --count Count only; search/definition/references/callers/callees/symbols/files/find/unused ignore --limit, impact/hotspots still use visible page counts"); Console.WriteLine(" --since Filter to files modified since this timestamp (ISO 8601)"); Console.WriteLine(" --depth Max BFS depth for impact analysis (default: 5)"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 91c7088c83..82484a190f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -393,6 +393,8 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (TryWriteParseError(options, "callers")) return CommandExitCodes.UsageError; + if (TryRejectMetadataKindForGraphCommand("callers", options.Kind)) + return CommandExitCodes.UsageError; if (!TryResolveNameExactMode(options, "callers", out var exact, out var exactError)) { Console.Error.WriteLine(exactError); @@ -494,6 +496,8 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (TryWriteParseError(options, "callees")) return CommandExitCodes.UsageError; + if (TryRejectMetadataKindForGraphCommand("callees", options.Kind)) + return CommandExitCodes.UsageError; if (!TryResolveNameExactMode(options, "callees", out var exact, out var exactError)) { Console.Error.WriteLine(exactError); @@ -3033,7 +3037,7 @@ private static void WriteLangHint(string? lang, DbReader reader) private static readonly string[] AllValidKinds = ["class", "delegate", "enum", "event", "function", "import", "interface", "namespace", "property", "struct"]; private static readonly string[] AllValidReferenceKinds = - ["call", "instantiate", "subscribe"]; + ["annotation", "attribute", "call", "instantiate", "subscribe"]; private static void WriteKindHint(string? kind, DbReader reader) { @@ -3067,6 +3071,44 @@ private static void WriteGraphReferenceKindHint(string command, string? kind, bo Console.Error.WriteLine($"Hint: '{kind}' is not a known reference kind for '{command}'. Available reference kinds: {string.Join(", ", AllValidReferenceKinds)}"); } + // Reference kinds that are valid `references --kind` values but NOT valid + // `callers --kind` / `callees --kind` values. A metadata row (`attribute` / + // `annotation`) is attributed to its enclosing body-range symbol rather than + // to the annotated target itself, so `callers Obsolete --kind attribute` and + // equivalent `callees` queries return structurally wrong answers: method-level + // metadata is reported under the enclosing class, and file-level targets + // like `[assembly: ...]` drop entirely because `container_name` is null. + // Reject these kinds at the CLI boundary and redirect users to + // `references --kind attribute|annotation` (which IS correct). + // `references --kind` では有効だが、`callers --kind` / `callees --kind` では + // 使ってはいけない reference kind。metadata 行は注釈対象そのものではなく + // body-range 上の外側シンボルに帰属するため、`callers` / `callees` でこの kind を + // 受け付けると構造的に誤答する(メソッドレベルは外側クラスに寄り、 + // `[assembly: ...]` のようなファイルレベルは `container_name = null` で丸ごと消える)。 + // CLI 境界で弾き、正しい列挙パスである `references --kind attribute|annotation` に誘導する。 + private static readonly HashSet MetadataReferenceKinds = new(StringComparer.Ordinal) + { + "attribute", "annotation", + }; + + /// + /// Reject `--kind attribute` / `--kind annotation` on commands (`callers` / `callees`) + /// whose data model cannot answer metadata questions correctly. Returns true if the + /// kind was rejected; the caller should then return `CommandExitCodes.UsageError`. + /// `callers` / `callees` のようにデータモデル的に metadata に答えられないコマンドで + /// `--kind attribute` / `--kind annotation` を弾く。弾いた場合 true を返すので、 + /// 呼び出し側は `CommandExitCodes.UsageError` を返すこと。 + /// + private static bool TryRejectMetadataKindForGraphCommand(string command, string? kind) + { + if (string.IsNullOrWhiteSpace(kind) || !MetadataReferenceKinds.Contains(kind)) + return false; + + Console.Error.WriteLine($"Error: '--kind {kind}' is not supported on '{command}'. Metadata references are attributed to the enclosing body-range symbol rather than the annotated target, so `{command} --kind {kind}` cannot return accurate rows (file-level targets such as `[assembly: ...]` drop entirely)."); + Console.Error.WriteLine($"Hint: use `cdidx references --kind {kind}` for metadata enumeration instead."); + return true; + } + private static void WriteGraphSupportHint(string? lang) { if (lang != null && !ReferenceExtractor.SupportsLanguage(lang)) diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index e082dd26a7..62ee379d21 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -63,6 +63,12 @@ THEN 2 ELSE 0 END"; private const string InvokeReferenceKindsSql = "('call', 'instantiate')"; + // Reference kinds that participate in the call-graph (callers/callees/hotspots). Metadata + // kinds such as `attribute` / `annotation` are excluded so they do not inflate the graph + // with non-call edges (issue #293). + // call-graph (callers/callees/hotspots) に参加する reference kind。`attribute` / `annotation` + // のようなメタデータ kind は非呼び出しエッジなのでここから除外する (issue #293)。 + internal const string CallGraphReferenceKindsSql = "('call', 'instantiate', 'subscribe')"; // Reference kinds that represent compile-time type/member references (e.g. C# `nameof(X)`, // `typeof(T)`, Java `T.class`). They are intentionally excluded from default `callers` / @@ -681,18 +687,45 @@ FROM symbol_references r if (referenceKind != null) sql += $" AND {BuildGraphSupportedLanguagePredicate(cmd, "f", "graphLang")}"; + var referencesSuffixAlias = ComputeCSharpAttributeSuffixAlias(query, lang, referenceKind); + // When the alias fires without an explicit `lang` / `--kind` scope we still need + // to keep it from bleeding into non-C# rows or non-attribute rows. The SQL guard + // clamps the alias disjunct to `f.lang = 'csharp' AND r.reference_kind = 'attribute'` + // so unscoped `references FooAttribute` only picks up real C# attribute sites. + // alias が `--lang` / `--kind` スコープなしで発火するときも、C# 以外の行や + // attribute 以外の行を拾わないように、SQL 側で `f.lang = 'csharp' AND + // r.reference_kind = 'attribute'` に限定する。 + var referencesAliasScope = referencesSuffixAlias != null + ? " AND f.lang = 'csharp' AND r.reference_kind = 'attribute'" + : string.Empty; if (query != null) { // --exact: Unicode-aware equality when FoldReady (#86), else ASCII COLLATE NOCASE. // Fold path: r.symbol_name_folded = @qFolded (indexed), query pre-folded in .NET. // Fallback: r.symbol_name = @q COLLATE NOCASE (indexed by idx_symbol_refs_name_nocase). + // When the query ends with C# attribute suffix `Attribute`, also OR against the + // suffix-stripped alias so `references FooAttribute --exact` reaches the idiomatic + // `[Foo]` reference site stored with `symbol_name = "Foo"`. In substring mode we + // still LIKE-match `%FooAttribute%` and add only the exact stripped alias to avoid + // overmatching unrelated names (e.g. `FooAuditLog`) that share the stripped prefix. + // The alias disjunct is scoped to C# attribute rows to avoid false positives. // --exact: FoldReady なら Unicode 折り畳み経路、未 ready なら ASCII NOCASE へ fallback。 + // C# の `Attribute` suffix が付いたクエリは、suffix を外した別名とも照合する。 + // 部分一致モードでは `%FooAttribute%` をそのまま使い、別名側は exact 照合だけを OR + // することで `FooAuditLog` など無関係な名前を巻き込まないようにする。 + // 別名節は C# の attribute 行に限定し、誤一致を避ける。 if (exact && _foldReady) - sql += " AND r.symbol_name_folded = @query"; + sql += referencesSuffixAlias != null + ? $" AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{referencesAliasScope}))" + : " AND r.symbol_name_folded = @query"; else if (exact) - sql += " AND r.symbol_name = @query COLLATE NOCASE"; + sql += referencesSuffixAlias != null + ? $" AND (r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}))" + : " AND r.symbol_name = @query COLLATE NOCASE"; else - sql += " AND r.symbol_name LIKE @query ESCAPE '\\'"; + sql += referencesSuffixAlias != null + ? $" AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{referencesAliasScope}))" + : " AND r.symbol_name LIKE @query ESCAPE '\\'"; } if (referenceKind != null) sql += " AND r.reference_kind = @referenceKind"; @@ -721,6 +754,20 @@ FROM symbol_references r else queryParam = query; cmd.Parameters.AddWithValue("@query", queryParam); + if (referencesSuffixAlias != null) + { + // Exact-match alias value is used both in --exact paths (folded / NOCASE) + // and in the substring path (COLLATE NOCASE exact OR to bypass LIKE noise). + // In the folded --exact branch the alias is pre-folded; the substring branch + // uses the raw stripped form because the OR clause is a literal `=` comparison. + // exact 用の別名値は --exact 経路(folded / NOCASE)と部分一致経路(LIKE ノイズを + // 避けるための COLLATE NOCASE の等値 OR)の両方で使う。folded 経路だけは事前に + // 折りたたみ、部分一致経路は生の stripped 形をそのまま使う。 + var aliasParam = exact && _foldReady + ? NameFold.Fold(referencesSuffixAlias) ?? referencesSuffixAlias + : referencesSuffixAlias; + cmd.Parameters.AddWithValue("@queryAttributeAlias", aliasParam); + } cmd.Parameters.AddWithValue("@rankingQuery", query.Trim()); cmd.Parameters.AddWithValue("@rankingQueryPrefix", $"{EscapeLikeQuery(query.Trim())}%"); } @@ -774,14 +821,24 @@ FROM symbol_references r WHERE 1=1"; innerSql += $" AND {BuildGraphSupportedLanguagePredicate(cmd, "f", "graphLang")}"; + var countSuffixAlias = ComputeCSharpAttributeSuffixAlias(query, lang, referenceKind); + var countAliasScope = countSuffixAlias != null + ? " AND f.lang = 'csharp' AND r.reference_kind = 'attribute'" + : string.Empty; if (query != null) { if (exact && _foldReady) - innerSql += " AND r.symbol_name_folded = @query"; + innerSql += countSuffixAlias != null + ? $" AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{countAliasScope}))" + : " AND r.symbol_name_folded = @query"; else if (exact) - innerSql += " AND r.symbol_name = @query COLLATE NOCASE"; + innerSql += countSuffixAlias != null + ? $" AND (r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}))" + : " AND r.symbol_name = @query COLLATE NOCASE"; else - innerSql += " AND r.symbol_name LIKE @query ESCAPE '\\'"; + innerSql += countSuffixAlias != null + ? $" AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{countAliasScope}))" + : " AND r.symbol_name LIKE @query ESCAPE '\\'"; } if (referenceKind != null) innerSql += " AND r.reference_kind = @referenceKind"; @@ -801,6 +858,13 @@ FROM symbol_references r ? NameFold.Fold(query) ?? query : query; cmd.Parameters.AddWithValue("@query", value); + if (countSuffixAlias != null) + { + var aliasParam = exact && _foldReady + ? NameFold.Fold(countSuffixAlias) ?? countSuffixAlias + : countSuffixAlias; + cmd.Parameters.AddWithValue("@queryAttributeAlias", aliasParam); + } } if (referenceKind != null) cmd.Parameters.AddWithValue("@referenceKind", referenceKind); @@ -829,14 +893,24 @@ FROM symbol_references r WHERE 1=1"; innerSql += $" AND {BuildGraphSupportedLanguagePredicate(cmd, "f", "graphLang")}"; + var totalSuffixAlias = ComputeCSharpAttributeSuffixAlias(query, lang, referenceKind); + var totalAliasScope = totalSuffixAlias != null + ? " AND f.lang = 'csharp' AND r.reference_kind = 'attribute'" + : string.Empty; if (query != null) { if (exact && _foldReady) - innerSql += " AND r.symbol_name_folded = @query"; + innerSql += totalSuffixAlias != null + ? $" AND (r.symbol_name_folded = @query OR (r.symbol_name_folded = @queryAttributeAlias{totalAliasScope}))" + : " AND r.symbol_name_folded = @query"; else if (exact) - innerSql += " AND r.symbol_name = @query COLLATE NOCASE"; + innerSql += totalSuffixAlias != null + ? $" AND (r.symbol_name = @query COLLATE NOCASE OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}))" + : " AND r.symbol_name = @query COLLATE NOCASE"; else - innerSql += " AND r.symbol_name LIKE @query ESCAPE '\\'"; + innerSql += totalSuffixAlias != null + ? $" AND (r.symbol_name LIKE @query ESCAPE '\\' OR (r.symbol_name = @queryAttributeAlias COLLATE NOCASE{totalAliasScope}))" + : " AND r.symbol_name LIKE @query ESCAPE '\\'"; } if (referenceKind != null) innerSql += " AND r.reference_kind = @referenceKind"; @@ -856,6 +930,13 @@ FROM symbol_references r ? NameFold.Fold(query) ?? query : query; cmd.Parameters.AddWithValue("@query", value); + if (totalSuffixAlias != null) + { + var aliasParam = exact && _foldReady + ? NameFold.Fold(totalSuffixAlias) ?? totalSuffixAlias + : totalSuffixAlias; + cmd.Parameters.AddWithValue("@queryAttributeAlias", aliasParam); + } } if (referenceKind != null) cmd.Parameters.AddWithValue("@referenceKind", referenceKind); @@ -882,6 +963,7 @@ WITH logical_references AS ( FROM symbol_references r JOIN files f ON r.file_id = f.id WHERE r.container_name IS NOT NULL + AND r.reference_kind IN {CallGraphReferenceKindsSql} AND {BuildGraphSupportedLanguagePredicate(cmd, "f", "graphLang")}" : @" SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, @@ -975,7 +1057,7 @@ FROM symbol_references r if (referenceKind != null) groupedSql += " AND r.reference_kind = @referenceKind"; else - groupedSql += NonInvocationReferenceKindsExclusion; + groupedSql += $" AND r.reference_kind IN {CallGraphReferenceKindsSql}"; if (exact && _foldReady) groupedSql += " AND r.symbol_name_folded = @query"; else if (exact) @@ -1026,7 +1108,7 @@ FROM symbol_references r if (referenceKind != null) groupedSql += " AND r.reference_kind = @referenceKind"; else - groupedSql += NonInvocationReferenceKindsExclusion; + groupedSql += $" AND r.reference_kind IN {CallGraphReferenceKindsSql}"; if (exact && _foldReady) groupedSql += " AND r.symbol_name_folded = @query"; else if (exact) @@ -1074,6 +1156,7 @@ WITH logical_references AS ( FROM symbol_references r JOIN files f ON r.file_id = f.id WHERE r.container_name IS NOT NULL + AND r.reference_kind IN {CallGraphReferenceKindsSql} AND {BuildGraphSupportedLanguagePredicate(cmd, "f", "graphLang")}" : @" SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, @@ -1171,7 +1254,7 @@ FROM symbol_references r if (referenceKind != null) groupedSql += " AND r.reference_kind = @referenceKind"; else - groupedSql += NonInvocationReferenceKindsExclusion; + groupedSql += $" AND r.reference_kind IN {CallGraphReferenceKindsSql}"; if (exact && _foldReady) groupedSql += " AND r.container_name_folded = @query"; else if (exact) @@ -1225,7 +1308,7 @@ FROM symbol_references r if (referenceKind != null) groupedSql += " AND r.reference_kind = @referenceKind"; else - groupedSql += NonInvocationReferenceKindsExclusion; + groupedSql += $" AND r.reference_kind IN {CallGraphReferenceKindsSql}"; if (exact && _foldReady) groupedSql += " AND r.container_name_folded = @query"; else if (exact) @@ -1312,13 +1395,19 @@ private List GetCallersExact(string symbolName, int limit, int off : @" AND r.symbol_name = @symbolName COLLATE NOCASE"; + // impact BFS must share the call-graph contract with `callers`/`callees`/`hotspots`, + // so event subscriptions (`Click += OnClick`) also participate in the transitive + // caller chain. Metadata edges (`attribute`, `annotation`) stay excluded. + // impact の BFS は `callers`/`callees`/`hotspots` と同じ call-graph 契約を共有し、 + // `subscribe` エッジ(`Click += OnClick` 等)も推移 caller に含める。`attribute` / + // `annotation` のような metadata エッジは引き続き除外する。 var sql = $@" WITH logical_references AS ( SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.line FROM symbol_references r JOIN files f ON r.file_id = f.id WHERE r.container_name IS NOT NULL - AND r.reference_kind IN {InvokeReferenceKindsSql} + AND r.reference_kind IN {CallGraphReferenceKindsSql} AND {supportedLangFilter} {nameCondition}"; if (lang != null) @@ -1623,6 +1712,39 @@ FROM symbols s return results; } + // C# convention: a class `FooAttribute` is used in source as `[Foo]`, so the reference + // site is stored with `symbol_name = "Foo"`. When a user queries with the class name + // (`references FooAttribute`, `inspect FooAttribute`, `analyze_symbol("FooAttribute")`), + // return the suffix-stripped form as an alias so the query still reaches the idiomatic + // use site. Only applies for C# scope — other languages do not share the convention. + // C# の規約: クラス `FooAttribute` はソース中で `[Foo]` として使われるため、参照サイトは + // `symbol_name = "Foo"` で保存される。ユーザーがクラス名で問い合わせたとき + // (`references FooAttribute` 等) でも慣用的な利用サイトに到達できるよう、 + // suffix を外した別名を返す。C# 以外の言語ではこの規約を持たないので適用しない。 + private static string? ComputeCSharpAttributeSuffixAlias(string? query, string? lang, string? referenceKind) + { + if (string.IsNullOrEmpty(query)) return null; + if (lang != null && !lang.Equals("csharp", StringComparison.OrdinalIgnoreCase)) return null; + // Only metadata lookups should apply the suffix alias: ordinary call-graph + // queries (`--kind call` / `instantiate` / `subscribe`) must not match `Foo()` + // call rows when the user typed `FooAttribute`. When `referenceKind` is null, + // the SQL side additionally constrains the alias clause to attribute rows only. + // metadata 参照の問い合わせ時だけ alias を適用する: `--kind call` などの call-graph + // クエリは `FooAttribute` と入力されたときに `Foo()` の call 行に一致してはならない。 + // referenceKind が null のときは SQL 側でも alias 節を attribute 行に限定する。 + if (referenceKind != null && !referenceKind.Equals("attribute", StringComparison.OrdinalIgnoreCase)) + return null; + const string suffix = "Attribute"; + // Case-insensitive suffix detection so `references myauditattribute` and + // `inspect MyAuditATTRIBUTE` still produce the `MyAudit` alias, matching the + // NOCASE / folded contract of the surrounding exact/substring query paths. + // 大文字小文字を無視して suffix を検出することで、`myauditattribute` や + // `MyAuditATTRIBUTE` のような形でも alias を生成できる。 + if (!query!.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return null; + if (query.Length <= suffix.Length) return null; + return query.Substring(0, query.Length - suffix.Length); + } + private List ResolveImpactFallbackNames(SymbolResult definition) { if (string.IsNullOrWhiteSpace(definition.Path) || string.IsNullOrWhiteSpace(definition.Name)) @@ -1649,6 +1771,30 @@ FROM symbols s using var reader = cmd.ExecuteTrackedReader(); while (reader.TrackedRead()) results.Add(reader.GetString(0)); + + // C# attribute naming convention: a class `FooAttribute` is used as `[Foo]` in source, + // so reference sites are stored with symbol_name `Foo`. Add the suffix-stripped alias + // for the resolved definition itself so impact on `FooAttribute` can find metadata-only + // usage sites. Only the resolved definition's own name gets the alias — applying the + // strip to every same-file fallback name (e.g. a nested `BarAttribute` inside the file + // that defines `FooAttribute`) would let `impact FooAttribute` falsely report `[Bar]` + // usages as part of `FooAttribute`'s blast radius. + // C# の属性命名規約: クラス `FooAttribute` はソースで `[Foo]` として使われ、参照サイトは + // symbol_name `Foo` で保存される。`FooAttribute` への impact でも metadata 参照サイトを + // 見つけられるよう、*解決済み定義自身* にのみサフィックスを外した別名を追加する。 + // same-file fallback 名全体(例: `FooAttribute` と同一ファイルに nested で存在する + // `BarAttribute`)にまで strip を適用すると、`impact FooAttribute` が `[Bar]` 利用を + // 誤って `FooAttribute` の影響範囲として報告してしまうため、定義自身だけに限定する。 + if (string.Equals(definition.Lang, "csharp", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(definition.Name) && + definition.Name.Length > "Attribute".Length && + definition.Name.EndsWith("Attribute", StringComparison.Ordinal)) + { + var stripped = definition.Name.Substring(0, definition.Name.Length - "Attribute".Length); + if (stripped.Length > 0 && !results.Contains(stripped)) + results.Add(stripped); + } + return results; } @@ -1667,6 +1813,19 @@ FROM symbols s FROM symbol_references r JOIN files src ON r.file_id = src.id WHERE src.path != @impactTargetPath"; + // `impact` heuristic file hints intentionally include metadata-only reference + // kinds (`attribute` / `annotation`). A rename or removal of `User` breaks + // `[JsonConverter(typeof(User))]` / `@Inject(User.class)` at compile time just + // as surely as it breaks `new User()`, so file-level blast-radius analysis + // must surface those sites as real dependencies. `callers` / `callees` still + // reject metadata kinds at the CLI / MCP boundary because those commands model + // the dynamic call graph, not the dependency graph. + // `impact` の heuristic file hint は metadata-only な参照 (`attribute` / + // `annotation`) も意図的に含める。`User` を rename / 削除すると + // `[JsonConverter(typeof(User))]` / `@Inject(User.class)` も compile-time で + // 壊れるため、ファイル単位の blast-radius 分析ではそれらも本物の依存として + // 出す必要がある。`callers` / `callees` は call graph を扱うので、metadata 種別 + // の拒否は引き続き CLI / MCP boundary 側で行う。 innerSql += $" AND {BuildGraphSupportedLanguagePredicate(cmd, "src", "impactDepsLang")}"; if (lang != null) innerSql += " AND src.lang = @lang"; @@ -1694,7 +1853,8 @@ FROM symbol_references r cmd.CommandText = $@" SELECT source_file_id, source_path, target_path, COUNT(*) AS reference_count, - GROUP_CONCAT(DISTINCT symbol_name) AS symbols + GROUP_CONCAT(DISTINCT symbol_name) AS symbols, + MAX(CASE WHEN logical_reference_kind IN ('attribute','annotation') THEN 1 ELSE 0 END) AS has_metadata_ref FROM ({innerSql}) edges GROUP BY source_file_id, source_path, target_path ORDER BY reference_count DESC, source_path, target_path"; @@ -1705,12 +1865,13 @@ FROM symbol_references r cmd.Parameters.AddWithValue($"@impactFallbackName{i}", fallbackNames[i]); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); - var candidates = new List<(long SourceFileId, FileDependencyResult Edge)>(); + var candidates = new List<(long SourceFileId, bool HasMetadataRef, FileDependencyResult Edge)>(); using var reader = cmd.ExecuteTrackedReader(); while (reader.TrackedRead()) { candidates.Add(( reader.GetInt64(0), + reader.GetInt32(5) == 1, new FileDependencyResult { SourcePath = reader.GetString(1), @@ -1720,10 +1881,37 @@ FROM symbol_references r })); } + // Metadata references only carry the short use-site name (`Foo` for `[Foo]`, + // `@Foo`). If multiple class-like definitions share the same unqualified name + // across namespaces / packages (e.g. `A.MyAuditAttribute` and + // `B.MyAuditAttribute`), we cannot uniquely attribute a `[MyAudit]` site to + // either target. Skip the metadata evidence bypass in that ambiguous case so + // `impact` does not over-report the blast radius of a rename / removal. + // metadata 参照は use-site 側の短縮名 (`[Foo]` / `@Foo` の `Foo`) しか持た + // ないため、namespace / package を跨いで同名の class-like 定義が複数存在 + // する場合、`[MyAudit]` 参照をどちらの target にも一意に紐付けられない。 + // そのような曖昧なケースでは metadata の evidence bypass を行わず、 + // `impact` が rename / 削除の影響範囲を過大報告しないようにする。 + var metadataBypassSafe = IsMetadataTargetUnambiguous(definition, lang, pathPatterns, excludePathPatterns, excludeTests); var evidenceCache = new Dictionary(); var filtered = new List(); foreach (var candidate in candidates) { + // Metadata-only consumers (attribute / annotation sites like `[MyAudit]` or + // `@Inject(User.class)`) legitimately lack structured type evidence in the + // source file. Bypass the evidence guard for those edges only when the + // class-like target is unambiguous so deps/impact can surface pure-attribute + // consumers without over-attributing same-named targets. + // metadata 専用の参照 (`[MyAudit]` や `@Inject(User.class)` のような attribute / + // annotation 利用) は、source 側のファイルに structured な型利用が無くても + // 正当な依存となるが、class-like target が一意に決まるときだけ evidence guard + // をスキップする。曖昧なときは下の evidence 要求へフォールスルーさせ、 + // 同名 target への誤帰属を防ぐ。 + if (candidate.HasMetadataRef && metadataBypassSafe) + { + filtered.Add(candidate.Edge); + continue; + } if (!evidenceCache.TryGetValue(candidate.SourceFileId, out var hasEvidence)) { hasEvidence = SourceFileHasStructuredTypeEvidence(candidate.SourceFileId, definition.Name); @@ -1740,6 +1928,115 @@ FROM symbol_references r return (filtered, truncated); } + // Returns true when the metadata target name resolves to at most one class-like + // symbol across the graph-supported languages. Ambiguous names (same unqualified + // name under different namespaces / packages) must not trigger the metadata + // evidence bypass because attribute / annotation reference rows only keep the + // short name and cannot disambiguate between them. + // graph 対応言語の中で class-like シンボルが高々 1 件しか存在しないときに true。 + // namespace / package を跨いで同名の class-like 定義が複数ある曖昧なケースでは + // attribute / annotation 参照行が短縮名しか持たず区別できないため、metadata の + // evidence bypass を許可しない。 + private bool IsMetadataTargetUnambiguous( + SymbolResult definition, + string? lang, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePathPatterns, + bool excludeTests) + { + if (string.IsNullOrWhiteSpace(definition.Name)) + return false; + using var cmd = _conn.CreateCommand(); + var supportedLangFilter = BuildGraphSupportedLanguagePredicate(cmd, "f", "metadataAmbigLang"); + // Count at symbol-identity level (path + line + name) rather than at path + // level, so two same-named class-like definitions in the same source file + // (e.g. `namespace A { class MyAuditAttribute { } } namespace B { class + // MyAuditAttribute { } }` both in one .cs file) still register as ambiguous. + // DISTINCT f.path alone would collapse them to 1 and falsely trigger the + // metadata bypass. + // 曖昧性は path 単位ではなく symbol identity 単位 (path + line + name) で数える。 + // 同じ .cs ファイル内に別名前空間で同名の class-like が 2 つあるケース + // (例: `namespace A { class MyAuditAttribute { } } namespace B { class + // MyAuditAttribute { } }`) でも ambiguity を 2 として検出できる。DISTINCT + // f.path のままだと 1 に潰れ、metadata bypass が誤って有効化される。 + // For C# specifically, only count class-like definitions that are + // plausible attribute metadata targets. We don't resolve base types + // transitively at SQL time, so the best portable approximation is + // "has an inheritance clause": any class declared with `: ...` is a + // potential attribute type (direct `: Attribute`, indirect + // `: BaseAudit` where BaseAudit itself derives from Attribute, or + // any other `: Base` chain). A plain `class MyAuditAttribute { }` + // with no `:` clause is not a valid `[MyAudit]` target at compile + // time, so excluding it prevents the metadata bypass from being + // falsely suppressed. We deliberately over-accept non-attribute + // derived classes rather than under-accept indirectly-derived + // attribute classes, because an invalid `[MyFoo]` against a + // non-attribute class would fail to compile and therefore not + // appear as a real reference. Other languages keep the broad + // class-like candidate set because their metadata-target markers + // don't match this signature shape. + // C# は SQL 時点で基底型を遡れないため、「何かを継承している + // class-like」を attribute 候補の近似として扱う。`: Attribute` の + // 直接継承も、`: BaseAudit` のような中間基底経由の間接継承も、 + // 何らかの `: Base` があれば候補に含める。継承節の無い plain + // `class MyAuditAttribute { }` だけを除外することで metadata + // bypass の誤抑止を防ぐ。非 attribute を過剰に含めるが、無効な + // `[MyFoo]` はコンパイルできないので実参照にはならず実害が無い。 + // 署名列が無い legacy DB では degrade して class 限定のみ使う。 + var metadataTargetKindExprF = BuildMetadataTargetKindExpr("f"); + var sql = $@" + SELECT COUNT(*) FROM ( + SELECT DISTINCT f.path, s.line, s.name + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE s.name = @metadataAmbigName COLLATE NOCASE + AND {metadataTargetKindExprF} + AND {supportedLangFilter}"; + if (lang != null) + { + sql += " AND f.lang = @metadataAmbigLangFilter"; + cmd.Parameters.AddWithValue("@metadataAmbigLangFilter", lang); + } + // Path / exclude-path parameters must be wrapped with `%...%` and escaped + // through EscapeLikeQuery so the LIKE semantics match the rest of the + // reader (search / references / callers / deps etc.). Passing the raw + // CLI value would require an anchored path like `%src/A/%` to match, so + // normal `--path src/A/` invocations would see zero in-scope definitions, + // the ambiguity count would underflow to 1, and the metadata bypass + // would falsely fire on what are actually ambiguous targets. + // path / exclude-path のパラメータは他の読み取り経路 (search / references / + // callers / deps 等) と同じ LIKE セマンティクスに合わせるため、 + // EscapeLikeQuery でエスケープした上で `%...%` で包んでバインドする。生値の + // まま渡すと、通常の `--path src/A/` のような呼び出しでは LIKE が一致せず、 + // 曖昧性カウントが 1 に過小化され、本来抑止すべき metadata bypass が + // 誤って発動してしまう。 + if (pathPatterns is { Count: > 0 }) + { + var ors = new List(pathPatterns.Count); + for (int i = 0; i < pathPatterns.Count; i++) + { + ors.Add($"f.path LIKE @metadataAmbigPath{i} ESCAPE '\\'"); + cmd.Parameters.AddWithValue($"@metadataAmbigPath{i}", $"%{EscapeLikeQuery(pathPatterns[i])}%"); + } + sql += " AND (" + string.Join(" OR ", ors) + ")"; + } + if (excludePathPatterns is { Count: > 0 }) + { + for (int i = 0; i < excludePathPatterns.Count; i++) + { + sql += $" AND f.path NOT LIKE @metadataAmbigExcludePath{i} ESCAPE '\\'"; + cmd.Parameters.AddWithValue($"@metadataAmbigExcludePath{i}", $"%{EscapeLikeQuery(excludePathPatterns[i])}%"); + } + } + if (excludeTests) + sql += $" AND NOT {TestPathCondition}"; + sql += ")"; + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@metadataAmbigName", definition.Name); + var count = Convert.ToInt32(cmd.ExecuteScalar() ?? 0); + return count <= 1; + } + private bool SourceFileHasStructuredTypeEvidence(long fileId, string typeName) { using var cmd = _conn.CreateCommand(); @@ -2290,6 +2587,85 @@ internal string GetFileColumnSql(string columnName, string? fallbackSql = null) return fallbackSql ?? "NULL"; } + // Build the language-aware metadata-target eligibility predicate used by + // `deps` (target_files / target_ambiguity) and `impact` + // (IsMetadataTargetUnambiguous). Returns a SQL fragment that evaluates to + // TRUE when a `(symbols s, files )` row should be counted as a + // plausible metadata target (`[Attribute]` / `@Annotation` / `@decorator`). + // Rules by language: + // - C# (`csharp`): only `kind = 'class'` with an inheritance clause + // (`signature LIKE '%: %'`). Transitive base-type resolution is not + // available at SQL time, so "has any inheritance clause" is the + // portable approximation for direct `: Attribute` plus indirect + // `: BaseAudit` where `BaseAudit` itself derives from Attribute. + // Extractor-driven authoritative `is_metadata_target` classification is + // tracked as a follow-up (issue #435) and would let `deps` / `impact` + // reject non-attribute classes like `class MyAuditAttribute : BaseService` + // that this heuristic cannot distinguish. + // For legacy-migration DBs whose `signature` column exists but stores + // NULL for individual C# class rows, fall back to the canonical C# + // attribute-naming convention (`name LIKE '%Attribute'`). This is + // strictly narrower than the previous unconditional NULL-signature + // pass-through and prevents every NULL-signature class from being + // treated as a plausible metadata target. DBs without any `signature` + // column at all degrade to the same naming heuristic. + // - JS / TS (`javascript` / `typescript`): decorators target runtime + // entities — classes and factory `function` definitions + // (e.g. `function sealed(target) {}` used as `@sealed class Foo {}`). + // TypeScript `interface` is a compile-time type-only construct and + // cannot be a decorator target at runtime; including it would let a + // same-name `interface` inject false ambiguity against the real + // `function` or `class` provider and silently drop the decorator edge. + // - Everything else (Java `@interface`, Kotlin `annotation class`, + // Scala annotation classes, etc.): the annotation target is a + // class-like declaration, so keep the original class-like candidate + // set (`class` / `struct` / `interface`). + // `deps` と `impact` で共有する言語別 metadata-target 適格性判定。 + // C# は `kind = 'class'` かつ継承節を持つ行を対象とする(直接/間接の Attribute 継承を + // ポータブルに近似するため)。signature 列は存在するが値が NULL の legacy-migration + // DB では C# の命名規約 `name LIKE '%Attribute'` にフォールバック — 従来の + // 無条件許容より厳密で、NULL-signature の全 class を metadata target 扱いしない。 + // signature 列自体が無い旧 DB も同じ命名規約ヒューリスティックを使う。 + // extractor 主導の authoritative な `is_metadata_target` 判定は follow-up(issue #435) + // として追跡しており、schema 化すれば `class MyAuditAttribute : BaseService` のような + // 非 attribute 継承も厳密に除外できるが、現状のヒューリスティックでは判別できない。 + // JS / TS は decorator が runtime entity (class / factory function) のみ対象。 + // TypeScript の `interface` は型定義で runtime decorator target にならないため除外し、 + // 同名 `interface` が本物の `function` / `class` provider を曖昧化するのを防ぐ。 + // それ以外は従来どおり class-like を候補にする。 + private string BuildMetadataTargetKindExpr(string fileAlias) + { + // C# clause — class only (interface/struct cannot be attribute targets). + // Non-NULL signature: accept any inheritance clause (`: %`) as the portable + // approximation of direct/indirect Attribute derivation (see issue #435). + // NULL signature: require the C# attribute naming convention + // (`name LIKE '%Attribute'`). This is strictly narrower than the previous + // unconditional NULL pass-through and prevents arbitrary NULL-signature + // classes on a legacy-migration DB from being treated as metadata targets. + // DBs missing the `signature` column entirely degrade to the same naming + // heuristic. + // C# は class のみ(interface/struct は attribute target にできない)。 + // 非 NULL signature は従来どおり継承節 `: %` で判定(直接/間接 Attribute の近似)。 + // NULL signature は C# 命名規約 `name LIKE '%Attribute'` に縮退 — 従来の + // 無条件許容より厳密で、legacy-migration DB で任意の NULL-signature class が + // metadata target 扱いされるのを防ぐ。signature 列欠落 DB も同じ命名規約を使う。 + var csharpClause = _symbolColumns.Contains("signature") + ? $"({fileAlias}.lang = 'csharp' AND s.kind = 'class' AND ((s.signature IS NOT NULL AND s.signature LIKE '%: %') OR (s.signature IS NULL AND s.name LIKE '%Attribute')))" + : $"({fileAlias}.lang = 'csharp' AND s.kind = 'class' AND s.name LIKE '%Attribute')"; + // JS / TS clause — decorators target runtime entities (classes and factory + // functions). TS `interface` is a type-only construct that cannot be a + // decorator target, so excluding it avoids false ambiguity against a + // real function/class provider sharing the same name. + // JS / TS: decorator は runtime entity (class / factory function) のみ対象。 + // TS の `interface` は型定義のため除外しないと同名 interface が偽の曖昧さを + // 発生させる。 + var jsClause = $"({fileAlias}.lang IN ('javascript','typescript') AND s.kind IN ('class','function'))"; + // All other graph-supported languages keep the original class-like set. + // その他の graph 対応言語は従来どおり class-like を対象にする。 + var otherClause = $"({fileAlias}.lang NOT IN ('csharp','javascript','typescript') AND s.kind IN ('class','struct','interface'))"; + return $"({csharpClause} OR {jsClause} OR {otherClause})"; + } + /// /// Compute file-level dependency edges: which files reference symbols defined in which other files. /// ファイル間の依存関係エッジを算出: どのファイルがどのファイルで定義されたシンボルを参照しているか。 @@ -2307,7 +2683,7 @@ public List GetFileDependencies(int limit = 50, string? la var sourceFilterAlias = "src"; var targetFilterAlias = "dst"; var sql = @" - WITH logical_references AS ( + WITH logical_references_primary AS ( SELECT src.id AS source_file_id, src.path AS source_path, src.lang AS source_lang, @@ -2318,6 +2694,21 @@ WITH logical_references AS ( FROM symbol_references r JOIN files src ON r.file_id = src.id WHERE 1 = 1"; + // `deps` intentionally includes metadata-only reference kinds + // (`attribute` / `annotation`). Same rationale as + // `GetFileDependencyHintsToResolvedType`: renaming or removing a type that + // is only referenced via `[JsonConverter(typeof(User))]` or + // `@Inject(User.class)` still breaks the annotated file at compile time, so + // file-level dependency analysis must treat those sites as real edges. + // Call-graph-specific commands (`callers` / `callees`) keep rejecting + // metadata kinds at the CLI / MCP boundary — that is a separate contract. + // `deps` は metadata-only 参照 (`attribute` / `annotation`) も意図的に + // 含める。`GetFileDependencyHintsToResolvedType` と同じ理由で、 + // `[JsonConverter(typeof(User))]` や `@Inject(User.class)` 経由でしか参照 + // されない型でも、rename / 削除すれば annotated ファイルは compile-time + // で壊れるため、ファイル単位の依存分析では本物の edge として扱う必要が + // ある。call-graph 専用コマンド (`callers` / `callees`) 側では metadata + // 種別の拒否を CLI / MCP boundary で引き続き行う — そちらは別契約。 sql += $" AND {BuildGraphSupportedLanguagePredicate(cmd, "src", "depsLang")}"; if (lang != null) sql += " AND src.lang = @lang"; @@ -2338,19 +2729,81 @@ FROM symbol_references r sql += @" GROUP BY src.id, src.path, src.lang, r.symbol_name, r.line, r.column_number, logical_reference_kind ), + logical_references AS ( + SELECT source_file_id, source_path, source_lang, symbol_name, line, column_number, logical_reference_kind, + 0 AS is_attribute_alias, + CASE WHEN logical_reference_kind IN ('attribute', 'annotation') THEN 1 ELSE 0 END AS is_metadata + FROM logical_references_primary + UNION ALL + -- C# attribute suffix alias: [Foo] in source is stored with symbol_name='Foo', + -- but the defining class is named 'FooAttribute'. Emit the canonical 'Foo' + 'Attribute' + -- form so deps can match the class file as a target. The alias rows are flagged + -- so the edges CTE can restrict them to class-like targets and avoid spurious + -- edges to unrelated functions / properties that happen to be named 'FooAttribute'. + -- C# 属性のサフィックス別名: ソース上の [Foo] は symbol_name='Foo' で保存されるが、 + -- 定義クラスは 'FooAttribute' 命名になるため、正規形 'Foo' + 'Attribute' を補って + -- deps がクラス側のファイルを target として join できるようにする。alias 行には + -- フラグを付け、edges CTE 側で class-like target だけに限定する。これにより、 + -- 偶然 'FooAttribute' という名前を持つ関数やプロパティへの誤ったエッジを防ぐ。 + SELECT source_file_id, source_path, source_lang, + symbol_name || 'Attribute' AS symbol_name, + line, column_number, logical_reference_kind, + 1 AS is_attribute_alias, + 1 AS is_metadata + FROM logical_references_primary + WHERE source_lang = 'csharp' + AND logical_reference_kind = 'attribute' + AND symbol_name NOT LIKE '%Attribute' + ), source_name_counts AS ( + -- Grouping includes is_metadata so metadata-only groups ([Foo] / @Foo) + -- can be restricted to class-like targets independently from non-metadata + -- call-graph groups that share the same symbol_name in the same file + -- (e.g. `Foo()` call + `[Foo]` attribute both present in the same source). + -- is_metadata を GROUP BY に含めることで、同じ source file / symbol_name を + -- 共有する metadata 行と call-graph 行 (例: 同じファイル内の `Foo()` 呼び出し + -- と `[Foo]` 属性) を別グループとして扱い、metadata 側だけに class-like + -- target 制限を掛けられるようにする。 SELECT source_file_id, source_path, source_lang, symbol_name, + is_attribute_alias, + is_metadata, COUNT(*) AS ref_count FROM logical_references - GROUP BY source_file_id, source_path, source_lang, symbol_name + GROUP BY source_file_id, source_path, source_lang, symbol_name, is_attribute_alias, is_metadata ), target_files AS ( - SELECT DISTINCT dst.path AS target_path, + -- Collapse per-symbol rows to one per (target_path, target_lang, symbol_name) + -- and remember whether any of the same-name symbols is a class-like kind + -- via MAX. Keeping kind in DISTINCT would split identical (path, lang, name) + -- rows when one file defines both a class and a same-name function (e.g. a + -- C# constructor), inflating the deps reference count. + -- (target_path, target_lang, symbol_name) 単位に集約し、同名のシンボルの + -- いずれかが class 系であるかを MAX で覚える。kind を DISTINCT に含めると、 + -- 同じ (path, lang, name) でも class と同名 function (C# のコンストラクタ等) + -- が別行として残り、deps の参照カウントが膨らんでしまう。 + -- has_metadata_target_kind further narrows the class-like set to targets + -- that can legitimately be referenced as [Attribute] metadata. For C# + -- we cannot resolve base types transitively at SQL time, so the best + -- portable approximation is an inheritance-clause check: any class + -- declared with a base list is a potential attribute type (direct or + -- indirect Attribute derivation). A plain class FooAttribute with no + -- base clause is not a valid [Foo] target at compile time. + -- Other languages keep the original class-like breadth. Legacy DBs + -- without a signature column degrade to the broad class-like set. + -- has_metadata_target_kind は [Attribute] metadata target として妥当な + -- class-like のみに絞る。C# は SQL 時点で基底型を遡れないため、継承節を + -- 持つクラスを候補とする近似を採る(直接・間接の Attribute 継承を + -- 取りこぼさない)。他言語は class-like 全体を残す。signature 列が無い + -- legacy DB では filter を無効化し class-like 全体に戻る。 + SELECT dst.path AS target_path, dst.lang AS target_lang, - s.name AS symbol_name + s.name AS symbol_name, + MAX(CASE WHEN s.kind IN ('class','struct','interface') THEN 1 ELSE 0 END) AS has_class_like_kind, + MAX(CASE WHEN " + BuildMetadataTargetKindExpr("dst") + @" + THEN 1 ELSE 0 END) AS has_metadata_target_kind FROM symbols s JOIN files dst ON s.file_id = dst.id WHERE 1 = 1"; @@ -2372,6 +2825,69 @@ FROM symbols s if (reverse && excludeTests) sql += $" AND NOT {TestPathCondition.Replace("f.path", $"{targetFilterAlias}.path")}"; sql += @" + GROUP BY dst.path, dst.lang, s.name + ), + metadata_raw_suppression AS ( + -- When a raw C# attribute reference '[Foo]' (stored as symbol_name='Foo', + -- logical_reference_kind='attribute') also has a synthetic suffix alias + -- row that resolves to a class-like 'FooAttribute' target, drop the raw + -- row to avoid creating a duplicate edge to any unrelated 'Foo' symbol + -- (method, property, local class) that merely shares the bare name. + -- 生の C# 属性参照 '[Foo]' (symbol_name='Foo', kind='attribute') に対して + -- 同じ source_file 内で 'FooAttribute' の synthetic alias 行が + -- class 系 target に解決できる場合、この行自体は落として + -- 同名の関数/プロパティ/ローカルクラス 'Foo' への誤依存を防ぐ。 + SELECT DISTINCT lrp.source_file_id, lrp.symbol_name + FROM logical_references_primary lrp + JOIN target_files tf_alias + ON tf_alias.target_lang = lrp.source_lang + AND tf_alias.symbol_name = lrp.symbol_name || 'Attribute' + AND tf_alias.has_metadata_target_kind = 1 + WHERE lrp.source_lang = 'csharp' + AND lrp.logical_reference_kind = 'attribute' + AND lrp.symbol_name NOT LIKE '%Attribute' + ), + target_ambiguity AS ( + -- Count class-like definitions at symbol-identity level rather than + -- file level. Two same-named class-like definitions in the same file + -- (e.g. `namespace A { class FooAttribute { } } namespace B { class + -- FooAttribute { } }` both inside one .cs file) collapse to a single + -- target_files row because target_files is GROUPed by dst.path, so + -- COUNT(DISTINCT target_path) alone would see count=1 and falsely + -- treat the metadata target as unambiguous. Joining target_files back + -- through files + symbols recovers the per-definition row count while + -- still inheriting target_files' lang / path / graph-supported scope + -- (since the join only keeps rows whose (path, lang, name) already + -- appear in target_files). + -- class-like 定義は path 単位ではなく symbol identity 単位で数える。 + -- 同じ .cs ファイル内に別名前空間で同名 class-like が 2 つあるケースは + -- target_files (dst.path で GROUP BY) 上では 1 行に潰れており、 + -- COUNT(DISTINCT target_path) だけでは count=1 となり metadata target + -- が一意と誤判定される。target_files から files + symbols に JOIN し直す + -- ことで定義単位の件数を復元する。JOIN が target_files 既存行にしか + -- 当たらないため、lang / path / graph-supported スコープはそのまま継承。 + SELECT tf.target_lang, + tf.symbol_name, + COUNT(*) AS class_like_target_count + FROM target_files tf + JOIN files dst + ON dst.path = tf.target_path + AND dst.lang = tf.target_lang + JOIN symbols s + ON s.file_id = dst.id + AND s.name = tf.symbol_name + -- Same language-aware metadata-eligibility filter as + -- target_files: C# restricts to `class` with inheritance + -- clause (interface/struct cannot be attribute targets); + -- JS/TS additionally accepts `function` (decorator + -- factory); others keep the class-like candidate set. + -- target_files と同じ言語別 metadata 適格性フィルタ。 + -- C# は class 限定 + 継承節 (interface/struct は除外)。 + -- JS/TS は decorator factory 用に function も許容。 + -- それ以外は class-like 全体を候補にする。 + AND " + BuildMetadataTargetKindExpr("dst") + @" + WHERE tf.has_metadata_target_kind = 1 + GROUP BY tf.target_lang, tf.symbol_name ), edges AS ( SELECT snc.source_path, @@ -2382,7 +2898,40 @@ FROM source_name_counts snc JOIN target_files tf ON tf.symbol_name = snc.symbol_name AND tf.target_lang = snc.source_lang + LEFT JOIN metadata_raw_suppression mrs + ON mrs.source_file_id = snc.source_file_id + AND mrs.symbol_name = snc.symbol_name + LEFT JOIN target_ambiguity ta + ON ta.target_lang = snc.source_lang + AND ta.symbol_name = snc.symbol_name WHERE snc.source_path != tf.target_path + -- All metadata references ([Foo] / @Foo) and their synthetic C# + -- suffix aliases must only match class-like target kinds; otherwise + -- a metadata reference would spuriously depend on any file that + -- merely defines a function / property / variable sharing the name. + -- Non-metadata call-graph refs keep matching any kind so e.g. a + -- constructor call can still tie back to a class definition. + -- metadata 参照 ([Foo] / @Foo) と C# の合成 alias 行はいずれも + -- class 系の target 種別にのみ一致させる。これを許すと同名の + -- 関数/プロパティ/変数を持つだけのファイルまで誤って依存してしまう。 + -- 非 metadata の call-graph 参照は任意の kind に一致させて構わない + -- (コンストラクタ呼び出しがクラス定義に結び付くケースなど)。 + AND (snc.is_metadata = 0 OR tf.has_metadata_target_kind = 1) + -- Drop raw C# '[Foo]' rows when the suffix alias already resolves + -- to a class-like 'FooAttribute' target in the same source file. + -- 同じ source file で suffix alias が class 系 'FooAttribute' に + -- 解決できている C# の raw '[Foo]' 行は落とす。 + AND NOT ( + snc.is_metadata = 1 + AND snc.is_attribute_alias = 0 + AND snc.source_lang = 'csharp' + AND mrs.source_file_id IS NOT NULL + ) + -- Metadata edges only survive when the target symbol resolves to + -- a single class-like definition within scope; ambiguous cases + -- (multiple same-name attribute / annotation classes) are dropped. + -- metadata エッジは同名 class 系 target が 1 つだけのときのみ残す。 + AND (snc.is_metadata = 0 OR COALESCE(ta.class_like_target_count, 0) <= 1) ) SELECT source_path, target_path, diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index ddc6bd834f..ff7888ff69 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -1030,6 +1030,7 @@ logical_references AS ( " + GetLogicalReferenceKindSql("sr.reference_kind") + @" AS logical_reference_kind FROM symbol_references sr JOIN files rf ON rf.id = sr.file_id + WHERE sr.reference_kind IN " + CallGraphReferenceKindsSql + @" GROUP BY rf.lang, sr.file_id, sr.symbol_name, sr.line, sr.column_number, logical_reference_kind ), global_reference_counts AS ( @@ -1243,6 +1244,7 @@ logical_references AS ( " + GetLogicalReferenceKindSql("sr.reference_kind") + @" AS logical_reference_kind FROM symbol_references sr JOIN files rf ON rf.id = sr.file_id + WHERE sr.reference_kind IN " + CallGraphReferenceKindsSql + @" GROUP BY rf.lang, sr.file_id, sr.symbol_name, sr.line, sr.column_number, logical_reference_kind ), global_reference_counts AS ( diff --git a/src/CodeIndex/Indexer/ReferenceExtractor.cs b/src/CodeIndex/Indexer/ReferenceExtractor.cs index 8e86f5de66..dfb8664561 100644 --- a/src/CodeIndex/Indexer/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/ReferenceExtractor.cs @@ -277,6 +277,60 @@ public static class ReferenceExtractor "string", "object", "void", "dynamic", "var", }; + // No-arg C# attribute name (`[Serializable]`, `[assembly: CLSCompliant]`, `[System.Obsolete]`, + // `[global::System.Obsolete]`, `[Alias::MyAttr]`, `[Required, Key]`, and their multi-line + // variants where `[` / `]` sit on separate lines). CallRegex only matches identifiers followed + // by `(`, so no-arg attributes would otherwise never be indexed. The pattern refuses to match + // when the identifier is followed by `(` (handled by CallRegex + TryClassifyMetadataReference) + // or a qualifier continuation (`.` / `::`). The match is gated downstream by + // `IsInsideCSharpAttributeRange`, so it is safe to relax the `[` / `,` left-anchor in favor of + // a word-boundary lookbehind — that lets a bare identifier on a line like ` Serializable` + // inside a multi-line attribute section still be recognized. + // 引数なしの C# attribute 名用 regex。`[Serializable]` などは CallRegex では拾えないため専用の + // 入口で捕捉する。`global::System.Obsolete` や `Alias::MyAttr` のように `::` 修飾子の付く形も + // 許容する。`[` / `,` / `]` が別行にある複数行形(例: `[\n Serializable\n]`)も取り込むため、 + // 左側は `[` / `,` ではなく単語境界だけでアンカーする。属性以外の位置で誤検出しないよう、 + // マッチ後は `IsInsideCSharpAttributeRange` で属性レンジ内かどうかを確認する。後続が `(` + // (CallRegex 経路)や `.` / `::`(qualifier 継続)なら名前を確定させず、行末(`$`)・`]`・`,` + // のいずれかで初めて採用する。 + private static readonly Regex CSharpNoArgAttributeRegex = new( + @"(?[A-Za-z_]\w*)(?:\s*<[^\n]+?>)?\s*(?=[\],]|$)", + RegexOptions.Compiled); + + // No-arg Java-family annotation (`@Deprecated`, `@Override`, `@org.junit.Test`, `@field:Deprecated`). + // CallRegex only catches `@Name(` forms; this pattern fills the bare `@Name` gap. The leading + // lookbehind `(?[A-Za-z_]\w*)\b(?!\s*[.(])", + RegexOptions.Compiled); + + // Languages whose `@Decorator(args)` / `@Annotation(args)` / `@Attribute(args)` syntax + // should produce `annotation` reference rows rather than `call` rows (issue #293). + // Swift uses `@available(...)`, `@objc`, `@MainActor`, etc. as compile-time metadata; + // Gradle/Groovy uses `@CompileStatic`, `@TaskAction`, etc. the same way. Without this + // reclassification, `callers` / `callees` / `hotspots` / `impact` on those languages + // get polluted with metadata edges. + // `@Decorator(args)` / `@Annotation(args)` / `@Attribute(args)` を `call` ではなく + // `annotation` として記録すべき言語 (issue #293)。Swift の `@available(...)` / `@objc` / + // `@MainActor` や、Gradle/Groovy の `@CompileStatic` / `@TaskAction` も compile-time + // metadata なので同じ扱いにする。再分類しないと `callers` / `callees` / `hotspots` / + // `impact` に metadata edge が混入する。 + private static readonly HashSet AnnotationLanguages = new(StringComparer.Ordinal) + { + "java", "kotlin", "scala", "typescript", "javascript", "swift", "gradle", + }; + + // Kotlin use-site target prefixes for annotations (e.g. `@field:Deprecated("msg")`, + // `@file:JvmName("Foo")`). Keep aligned with the Kotlin language spec use-site targets. + // Kotlin の use-site target 付き注釈用の接頭辞。 + private static readonly HashSet KotlinAnnotationTargets = new(StringComparer.Ordinal) + { + "field", "get", "set", "param", "setparam", "property", "receiver", "file", "delegate", "all", + }; + public static IReadOnlyCollection GetSupportedLanguages() => SupportedLanguages; public static bool SupportsLanguage(string? lang) => @@ -362,6 +416,25 @@ public static List Extract(long fileId, string? lang, string co var lines = content.Split('\n'); var structuralLines = StructuralLineMasker.MaskLines(language, lines); + var preparedLines = new string[lines.Length]; + for (var pi = 0; pi < lines.Length; pi++) + preparedLines[pi] = PrepareLine(language, structuralLines[pi]); + // Pre-pass C# attribute analysis so cross-line `[\n Foo("x")\n]` and parameter + // attributes `void M([Attr] T x)` are classified consistently with same-line `[Foo]`. + // 行を跨いだ `[\n Foo("x")\n]` やパラメータ属性 `void M([Attr] T x)` も、同一行の `[Foo]` と + // 同じ判定で属性として扱えるように、事前パスで C# 属性セクションの範囲を構築する。 + var csharpAttrTables = language == "csharp" + ? BuildCSharpAttributeRanges(preparedLines) + : (null, null); + var csharpAttrRanges = csharpAttrTables.Item1; + // Top-level (paren-depth 0) zones inside attribute sections. Used by the no-arg + // attribute regex so that enum / qualified-constant identifiers appearing inside + // attribute argument lists (e.g. `AllowNumbers` in `[JsonConverter(ConverterStrategy.AllowNumbers)]`) + // are not misclassified as no-arg attribute references. + // 属性セクション内で paren 深さ 0 の top-level ゾーンだけを別テーブルで持つ。複数行 + // `[...]` の引数中に現れる enum / 修飾定数(`ConverterStrategy.AllowNumbers` など)が + // no-arg attribute として誤分類されないよう、no-arg 属性用ゲートに使う。 + var csharpAttrTopLevelRanges = csharpAttrTables.Item2; var definitionNamesByLine = symbols .GroupBy(symbol => symbol.Line) .ToDictionary(group => group.Key, group => group.Select(symbol => symbol.Name).ToHashSet(StringComparer.Ordinal)); @@ -404,9 +477,11 @@ public static List Extract(long fileId, string? lang, string co { var lineNumber = i + 1; var originalLine = lines[i]; - var preparedLine = PrepareLine(language, structuralLines[i]); + var preparedLine = preparedLines[i]; if (string.IsNullOrWhiteSpace(preparedLine)) continue; + var csharpAttrRangesOnLine = csharpAttrRanges?[i]; + var csharpAttrTopLevelOnLine = csharpAttrTopLevelRanges?[i]; var context = originalLine.Trim(); if (context.Length == 0) @@ -614,7 +689,54 @@ SymbolRecord ResolveContainerForCall(int column) if (definitionNames != null && definitionNames.Contains(name)) continue; - AddReference(references, seen, fileId, match, "call", context, lineNumber, callContainer); + // issue #293: reclassify C# attribute / Java/Kotlin/Scala/TypeScript annotation + // usages with arguments so they do not pollute the call-graph as phantom `call` rows. + // issue #293: 引数付きの C# attribute と Java/Kotlin/Scala/TypeScript annotation 使用を + // `call` ではなく専用の種別に分類し、call-graph の phantom エッジを防ぐ。 + var insideCSharpAttributeRange = csharpAttrRangesOnLine != null + && IsInsideCSharpAttributeRange(csharpAttrRangesOnLine, callIndex); + var metadataKind = TryClassifyMetadataReference(language, preparedLine, callIndex, insideCSharpAttributeRange); + AddReference(references, seen, fileId, match, metadataKind ?? "call", context, lineNumber, callContainer); + } + + // issue #293: bare no-arg attributes / annotations are invisible to CallRegex because + // it requires `(`. Emit them from dedicated regexes so `[Serializable]` / `@Deprecated` + // and their siblings still populate the reference table. + // issue #293: 引数なしの属性・アノテーションは `(` が必須な CallRegex では拾えないため、 + // 専用 regex から `[Serializable]` / `@Deprecated` などの素形を reference テーブルへ反映する。 + if (language == "csharp" && csharpAttrTopLevelOnLine != null && csharpAttrTopLevelOnLine.Count > 0) + { + foreach (Match match in CSharpNoArgAttributeRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + var nameIndex = match.Groups["name"].Index; + // Gate on the attribute-section top-level (paren-depth 0) zones only, so + // identifiers that sit inside an attribute's argument list (e.g. + // `ConverterStrategy.AllowNumbers` in `[JsonConverter(...)]`) are not + // misclassified as no-arg attributes. + // 属性セクションの top-level(paren 深さ 0)ゾーンでのみ採用する。属性の + // 引数リスト内にある識別子(`[JsonConverter(ConverterStrategy.AllowNumbers)]` + // の `AllowNumbers` など)を no-arg 属性として誤分類しないため。 + if (!IsInsideCSharpAttributeRange(csharpAttrTopLevelOnLine, nameIndex)) + continue; + if (IsIgnoredCallName(language, name)) + continue; + if (definitionNames != null && definitionNames.Contains(name)) + continue; + AddReference(references, seen, fileId, match, "attribute", context, lineNumber, container); + } + } + else if (AnnotationLanguages.Contains(language)) + { + foreach (Match match in NoArgAnnotationRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (IsIgnoredCallName(language, name)) + continue; + if (definitionNames != null && definitionNames.Contains(name)) + continue; + AddReference(references, seen, fileId, match, "annotation", context, lineNumber, container); + } } } @@ -2543,6 +2665,434 @@ private static bool IsConstructorCallName(string language, string preparedLine, private static bool IsIdentifierChar(char ch) => char.IsLetterOrDigit(ch) || ch == '_'; + /// + /// Classify a call-looking identifier as an attribute/annotation when it appears inside + /// a C# `[...]` attribute list or is preceded by a Java-family `@` marker. Returns null + /// for ordinary method calls so the caller emits the default `call` reference kind. + /// 呼び出しに見える識別子を、C# の `[...]` 属性リスト内や Java 系 `@` 付き注釈に該当する + /// 場合に専用の reference kind へ分類する。通常の呼び出しは null を返して既定の `call` を維持する。 + /// + private static string? TryClassifyMetadataReference( + string language, + string preparedLine, + int nameIndex, + bool insideCSharpAttributeRange) + { + if (language == "csharp") + return insideCSharpAttributeRange ? "attribute" : null; + + var probe = nameIndex - 1; + while (probe >= 0 && char.IsWhiteSpace(preparedLine[probe])) + probe--; + if (probe < 0) + return null; + + if (AnnotationLanguages.Contains(language)) + return IsAnnotationContext(preparedLine, probe) ? "annotation" : null; + + return null; + } + + /// + /// Build per-line column ranges that identify C# `[...]` attribute sections. Handles + /// declaration-position detection (including parameter attributes preceded by `(` / `,` + /// via forward look-ahead) and multi-line `[\n ... \n]` sections. Each inner list holds + /// ordered `(startColumn, endColumnExclusive)` ranges that are inside an attribute section + /// on that line. Call sites whose name column falls inside one of these ranges are + /// reclassified as `attribute` instead of `call`. + /// C# の `[...]` 属性セクションを行ごとの列範囲で表すテーブルを構築する。 + /// `(` / `,` の直後に置かれるパラメータ属性を forward lookahead で、複数行にわたる + /// `[\n ... \n]` 属性を跨行トラッキングで検出する。各行のリストは属性セクションに含まれる + /// `(開始列, 終端列 (exclusive))` のレンジを保持し、呼び出し名の列がどれかのレンジに含まれる場合に + /// `call` ではなく `attribute` へ再分類する。 + /// + private static (List>, List>) BuildCSharpAttributeRanges(string[] preparedLines) + { + var perLine = new List>(preparedLines.Length); + var perLineTopLevel = new List>(preparedLines.Length); + for (var i = 0; i < preparedLines.Length; i++) + { + perLine.Add(new List<(int, int)>()); + perLineTopLevel.Add(new List<(int, int)>()); + } + + // Stack entries capture the opening `[` position, whether that bracket was at + // a C# declaration (attribute) position, and a snapshot of the global paren depth + // at that moment. The snapshot lets us compute an attribute-section-local paren + // depth (`parenDepth - parenDepthAtOpen`), which is what the top-level zone tracking + // uses so that parameter attributes like `void M([Attr] int x)` still have their + // attribute-list top level at section-local depth 0 even though the global depth + // is inside the method's parameter list. + // スタックは `[` の位置、その bracket が属性位置だったか、および開いた瞬間の + // グローバル paren 深さのスナップショットを保持する。スナップショットを使うと + // 属性セクション内ローカルの paren 深さ (`parenDepth - parenDepthAtOpen`) が + // 得られるので、`void M([Attr] int x)` のように外側の method 引数リストの中で + // 開く属性セクションでも、セクション内では top-level (local depth 0) として扱える。 + var bracketStack = new Stack<(int li, int ci, bool isAttr, int parenDepthAtOpen)>(); + char lastMeaningful = '\0'; + int parenDepth = 0; + bool lastClosedBracketWasAttribute = false; + + // Top-level zone tracking: while we are inside an attribute section and the paren + // depth is at the section's open snapshot (section-local depth 0), the current zone + // span is open. When parens open inside the section we close it; when they fully + // close again we reopen. When the attribute section itself closes, we emit the span. + // top-level ゾーン追跡: 属性セクション内かつセクションローカルの paren 深さが 0 の + // あいだだけゾーンを開いておき、セクション内の `(` で閉じ、`)` で再び開く。 + // セクションが閉じる `]` で確定させる。 + int topZoneStartLi = -1; + int topZoneStartCi = 0; + + void EmitTopZone(int endLi, int endCi) + { + if (topZoneStartLi < 0) + return; + for (var l = topZoneStartLi; l <= endLi; l++) + { + int s = (l == topZoneStartLi) ? topZoneStartCi : 0; + int e = (l == endLi) ? endCi : preparedLines[l].Length; + if (e > s) + perLineTopLevel[l].Add((s, e)); + } + topZoneStartLi = -1; + } + + for (var li = 0; li < preparedLines.Length; li++) + { + var line = preparedLines[li]; + for (var ci = 0; ci < line.Length; ci++) + { + var c = line[ci]; + if (char.IsWhiteSpace(c)) + continue; + + if (c == '(') + { + // If the innermost enclosing bracket is an attribute section and we are + // currently at that section's local top level, close the top-level zone + // just before the `(`. Use the stack top's `parenDepthAtOpen` snapshot so + // parameter attributes inside an outer `(...)` still get their top level + // tracked correctly. + // 直近の `[` が属性セクションで、かつその section-local 深さで top-level のとき、 + // `(` 直前でゾーンを閉じる。外側の `(...)` の中で開く属性セクションにも対応するため、 + // グローバル depth ではなくスタック top の開いたときの snapshot と比較する。 + if (bracketStack.Count > 0) + { + var top = bracketStack.Peek(); + if (top.isAttr && parenDepth == top.parenDepthAtOpen && topZoneStartLi >= 0) + EmitTopZone(li, ci); + } + parenDepth++; + lastMeaningful = c; + continue; + } + if (c == ')') + { + if (parenDepth > 0) + { + parenDepth--; + // If the innermost `[` is an attribute section and we just returned + // to that section's local top level, reopen the top-level zone. + // 直近の `[` が属性セクションで、section-local top-level に戻ってきたら + // top-level ゾーンを再開する。 + if (bracketStack.Count > 0) + { + var top = bracketStack.Peek(); + if (top.isAttr && parenDepth == top.parenDepthAtOpen && topZoneStartLi < 0) + { + topZoneStartLi = li; + topZoneStartCi = ci + 1; + } + } + } + lastMeaningful = c; + continue; + } + + if (c == '[') + { + bool isAttr = EvaluateCSharpAttributePosition( + lastMeaningful, lastClosedBracketWasAttribute, preparedLines, li, ci); + bracketStack.Push((li, ci, isAttr, parenDepth)); + if (isAttr && topZoneStartLi < 0) + { + // Start top-level zone just after the `[` so the `[` itself is not + // inside the zone. Section-local depth is 0 by construction at the + // open bracket. + // `[` 直後から top-level ゾーンを開始する。開いた瞬間は section-local 深さ 0。 + topZoneStartLi = li; + topZoneStartCi = ci + 1; + } + lastMeaningful = c; + continue; + } + + if (c == ']') + { + if (bracketStack.Count > 0) + { + var opened = bracketStack.Pop(); + lastClosedBracketWasAttribute = opened.isAttr; + if (opened.isAttr) + { + // Record the attribute section span for every line it covers so + // cross-line `[\n Foo("x")\n]` also classifies `Foo` as attribute. + // 属性セクションがまたぐ全ての行に対して範囲を記録し、 + // `[\n Foo("x")\n]` のような跨行ケースでも `Foo` が属性として分類されるようにする。 + for (var l = opened.li; l <= li; l++) + { + int s = (l == opened.li) ? opened.ci : 0; + int e = (l == li) ? ci + 1 : preparedLines[l].Length; + perLine[l].Add((s, e)); + } + // Close the top-level zone at the `]`. Section-local depth should + // be 0 here (we are at the closing bracket of this section) — if + // it is not, we drop the open zone because paren balancing was + // malformed. + // `]` で top-level ゾーンを確定する。section-local 深さが 0 のはず。 + // 不整合入力ならゾーンを捨てる。 + if (parenDepth == opened.parenDepthAtOpen) + { + EmitTopZone(li, ci + 1); + } + else + { + topZoneStartLi = -1; + } + } + } + else + { + lastClosedBracketWasAttribute = false; + } + lastMeaningful = c; + continue; + } + + lastMeaningful = c; + } + } + + return (perLine, perLineTopLevel); + } + + /// + /// Decide whether a `[` token sits at a C# attribute position based on the immediately + /// preceding meaningful character. `(` / `,` (parameter attributes) are disambiguated via + /// forward look-ahead because both attributes and C# 12 collection expressions can follow. + /// `[` が C# の属性位置にあるかを、直前の非空白文字から判定する。`(` / `,` の直後は + /// パラメータ属性にも collection expression にもなりうるため、forward lookahead で区別する。 + /// + private static bool EvaluateCSharpAttributePosition( + char lastMeaningful, + bool lastClosedBracketWasAttribute, + string[] preparedLines, + int startLi, + int startCi) + { + // Start of file or after a scope/statement boundary — attribute position. + // ファイル先頭、あるいはスコープ・文境界の直後は属性位置。 + if (lastMeaningful is '\0' or '{' or '}' or ';') + return true; + + // Chained attribute list `[A][B]`: the prior `]` must have closed an attribute section. + // `arr[i][Compute()]` → the prior `]` closed an indexer, so stays `call`. + // 連続した属性リスト `[A][B]` は、直前の `]` が属性セクションを閉じていたときのみ属性扱い。 + // `arr[i][Compute()]` の `]` は indexer を閉じているため `call` のまま。 + if (lastMeaningful == ']') + return lastClosedBracketWasAttribute; + + // Parameter / type-parameter / lambda attribute candidates (`(`, `,`, `<`, `=`): + // `void M([Attr] T x)`, `class C<[Attr] T>`, `var f = [Attr] () => body`, or + // `Consume([Make()])`. Disambiguate by scanning forward to the matching `]` and + // checking whether the next meaningful token begins a declaration (identifier / + // `@` / `(` for tuple types or lambda parameter lists / `[` chained). + // パラメータ / 型パラメータ / ラムダ属性候補 (`(`, `,`, `<`, `=`) は + // `void M([Attr] T x)`・`class C<[Attr] T>`・`var f = [Attr] () => body`・ + // `Consume([Make()])` いずれにもなりうる。対応する `]` まで進んで次トークンが + // 宣言やラムダを開始するか(識別子 / `@` / tuple・ラムダ仮引数の `(` / chained `[`)で区別する。 + if (lastMeaningful is '(' or ',' or '<' or '=') + return IsCSharpAttributeFollowedByDeclaration(preparedLines, startLi, startCi); + + return false; + } + + /// + /// Keywords that indicate the preceding `[...]` is an expression (collection / pattern / + /// switch target) rather than an attribute section when they appear after `]`. + /// `]` の直後に現れると、直前の `[...]` が属性ではなく式(collection / pattern / switch 対象) + /// であることを示す C# のキーワード集合。 + /// + private static readonly HashSet CSharpExpressionContinuationKeywords = new(StringComparer.Ordinal) + { + "is", "as", "switch", "with", "when", + }; + + /// + /// Scan forward from a `[` to its matching `]` (skipping balanced parens) and return true + /// when the next meaningful character begins an identifier-like token. Works across lines so + /// `void M(\n [Attr]\n T x\n)` is recognized as a parameter attribute. + /// `[` から対応する `]` まで進んで、`]` の次の非空白文字が識別子を始める場合に true を返す。 + /// 行を跨ぐ走査にも対応しているため `void M(\n [Attr]\n T x\n)` も属性として認識される。 + /// + private static bool IsCSharpAttributeFollowedByDeclaration(string[] preparedLines, int startLi, int startCi) + { + var bracketDepth = 1; + var parenDepth = 0; + var li = startLi; + var ci = startCi + 1; + while (li < preparedLines.Length) + { + var line = preparedLines[li]; + while (ci < line.Length) + { + var c = line[ci]; + if (c == '(') + { + parenDepth++; + ci++; + continue; + } + if (c == ')') + { + if (parenDepth > 0) + parenDepth--; + ci++; + continue; + } + if (parenDepth > 0) + { + ci++; + continue; + } + if (c == '[') + { + bracketDepth++; + ci++; + continue; + } + if (c == ']') + { + bracketDepth--; + if (bracketDepth == 0) + { + ci++; + return NextTokenStartsDeclaration(preparedLines, li, ci); + } + ci++; + continue; + } + ci++; + } + li++; + ci = 0; + } + return false; + } + + /// + /// After the closing `]` of a candidate `[...]`, inspect the next meaningful token to decide + /// whether it begins a declaration. Accepts identifiers (except expression-continuation + /// keywords like `is` / `as` / `switch` / `with` / `when`), leading `@` (verbatim identifier), + /// `(` (tuple-typed parameter), and chained `[` (recurse for `[A][B]`). + /// 閉じ `]` の直後のトークンで宣言が始まるかを判定する。識別子(式継続の `is` / `as` / + /// `switch` / `with` / `when` は除外)、`@`(verbatim 識別子)、`(`(tuple パラメータ型)、 + /// `[`(`[A][B]` の連結)を受け入れる。 + /// + private static bool NextTokenStartsDeclaration(string[] preparedLines, int li, int ci) + { + while (li < preparedLines.Length) + { + var line = preparedLines[li]; + while (ci < line.Length && char.IsWhiteSpace(line[ci])) + ci++; + if (ci < line.Length) + { + var first = line[ci]; + if (first == '@' || first == '(') + return true; + if (first == '[') + return IsCSharpAttributeFollowedByDeclaration(preparedLines, li, ci); + if (!IsIdentifierChar(first)) + return false; + var start = ci; + while (ci < line.Length && IsIdentifierChar(line[ci])) + ci++; + var token = line.Substring(start, ci - start); + return !CSharpExpressionContinuationKeywords.Contains(token); + } + li++; + ci = 0; + } + return false; + } + + private static bool IsInsideCSharpAttributeRange(List<(int start, int end)> ranges, int index) + { + foreach (var (start, end) in ranges) + { + if (index >= start && index < end) + return true; + } + return false; + } + + private static bool IsAnnotationContext(string line, int probe) + { + // `@Annotation(args)` — direct marker. 直接 `@Annotation(args)` の場合。 + if (line[probe] == '@') + return true; + + // `@module.Annotation(args)` — walk past the dotted qualifier chain first so that + // both `@module.Annotation` and `@field:com.example.Annotation` land the probe on + // either `@` or the Kotlin use-site target `:`. + // `@module.Annotation(args)` や `@field:com.example.Annotation(args)` のように修飾子が + // 付く場合も対応するため、先にドット区切り修飾子チェーンを剥がしてから `@` または + // Kotlin の use-site target `:` を判定する。 + while (probe >= 0 && line[probe] == '.') + { + probe--; + while (probe >= 0 && IsIdentifierChar(line[probe])) + probe--; + while (probe >= 0 && char.IsWhiteSpace(line[probe])) + probe--; + } + + if (probe < 0) + return false; + + if (line[probe] == '@') + return true; + + // Kotlin use-site target: `@field:Deprecated("msg")` or + // `@field:com.example.Deprecated("msg")`. After unwinding the dotted qualifier, the + // probe lands on `:`; walk past the target identifier and confirm `@`. + // Kotlin の use-site target `@field:Deprecated("msg")` や + // `@field:com.example.Deprecated("msg")` では、ドット修飾子を剥がしたあと probe が `:` + // に着地するため、target 識別子を読み飛ばして `@` を確認する。 + if (line[probe] == ':') + { + var j = probe - 1; + var idEnd = j; + while (j >= 0 && IsIdentifierChar(line[j])) + j--; + if (j + 1 <= idEnd) + { + var target = line[(j + 1)..(idEnd + 1)]; + if (KotlinAnnotationTargets.Contains(target)) + { + var k = j; + while (k >= 0 && char.IsWhiteSpace(line[k])) + k--; + if (k >= 0 && line[k] == '@') + return true; + } + } + } + + return false; + } + private static bool UsesHashComments(string lang) => lang is "python" or "ruby" or "php" or "elixir" or "r" or "powershell" or "makefile" or "terraform" or "dockerfile" or "protobuf"; diff --git a/src/CodeIndex/Indexer/SymbolExtractor.cs b/src/CodeIndex/Indexer/SymbolExtractor.cs index 0326ac5831..b682b546d6 100644 --- a/src/CodeIndex/Indexer/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/SymbolExtractor.cs @@ -7233,6 +7233,107 @@ private static int FindCSharpSameLineBraceEndColumn(string line, int startColumn return -1; } + /// + /// Track multi-line C# `[...]` bracket sections across lines and blank out any text that + /// sits inside those sections, so downstream symbol regexes do not treat interior identifiers + /// as declarations. Activates whenever a `[` opens without a matching `]` on the same line, + /// regardless of whether the `[` sits at the start of the line (leading attribute) or deeper + /// inside the line (parameter attribute like `void M([\n Attr\n] T x)`, type-parameter + /// attribute like `class C<[\n Attr\n] T>`, delegate/lambda parameter attributes, etc.). + /// Single-line attribute lists continue to be handled by `StripLeadingCSharpAttributeLists`. + /// 複数行にまたがる C# `[...]` セクションを跨行で追跡し、内部の文字列を空白化することで + /// 下流のシンボル regex が内部の識別子を宣言として誤解釈しないようにする。`[` が行頭 + /// (空白の後)にある場合だけでなく、`void M([\n Attr\n] T x)` のようなパラメータ属性、 + /// `class C<[\n Attr\n] T>` のような型パラメータ属性、delegate / lambda のパラメータ属性など、 + /// 行の途中で開いて同一行で閉じない `[` でも作動する。同一行で完結する属性リストは + /// `StripLeadingCSharpAttributeLists` が引き続き担当する。 + /// + private static string StripMultiLineCSharpAttributeInterior(string line, ref int depth) + { + if (depth == 0) + { + // Scan the line for a `[` that is NOT closed on the same line. Everything before + // that `[` is real code (method header text like `void M(`, generic opener like + // `class C<`, etc.) and must be preserved so downstream declaration regexes can + // still recognize the surrounding construct. Everything from the unclosed `[` + // onward is blanked, and subsequent lines are blanked until the matching `]`. + // Only attribute-position `[` should trigger blanking — a multi-line indexer + // declaration such as `public int this[\n int i\n] => _items[i];` opens `[` + // immediately after the identifier `this`, which is NOT an attribute and must + // not be stripped (otherwise the indexer regex sees only `public int this` and + // the indexer silently disappears from symbols / definition / outline). Treat + // `[` as an attribute opener only when the immediately preceding non-whitespace + // character is not a word character (`[_A-Za-z0-9]`) and not `)` / `]` (which + // indicate indexer / array access on an expression result or chained indexer). + // 行内を走査し、同一行で閉じない `[` を探す。その `[` より前は通常のコード + // (`void M(` のようなメソッドヘッダ、`class C<` のようなジェネリック開口など) + // であり、下流の宣言 regex が外側の構文を認識できるように残す必要がある。 + // 閉じない `[` 以降は空白化し、対応する `]` が現れるまで後続行も空白化する。 + // `[` が属性位置にあるときだけ空白化する — `public int this[\n int i\n]` + // のような複数行インデクサ宣言では `this` 直後の `[` が属性でないため、 + // ここを削ってしまうとインデクサがシンボルから消える。直前の非空白文字が + // 語文字(`[_A-Za-z0-9]`)でも `)` / `]` でもない場合にのみ属性開口と判定する。 + int openIndex = -1; + int localDepth = 0; + for (int i = 0; i < line.Length; i++) + { + if (line[i] == '[') + { + if (localDepth == 0) + { + // Look back past whitespace for the character that introduces the `[`. + // 先行する非空白文字を探して `[` の導入子を判定する。 + int p = i - 1; + while (p >= 0 && (line[p] == ' ' || line[p] == '\t')) + p--; + if (p >= 0) + { + char prev = line[p]; + if (prev == '_' || (prev >= 'A' && prev <= 'Z') || (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') || prev == ')' || prev == ']') + { + // Not an attribute opener (e.g. `this[`, `arr[`, `(expr)[`, `arr[i][`). + // Treat this `[` as opaque — do not track depth, do not blank. + // 属性開口ではない(`this[`・`arr[`・`(expr)[`・`arr[i][` など)。 + // この `[` は追跡も空白化もしない。 + continue; + } + } + openIndex = i; + } + localDepth++; + } + else if (line[i] == ']') + { + if (localDepth > 0) + { + localDepth--; + if (localDepth == 0) + openIndex = -1; + } + } + } + + if (openIndex < 0 || localDepth <= 0) + return line; + + depth = localDepth; + return line.Substring(0, openIndex); + } + + // We are inside a multi-line attribute section. Walk the line, closing brackets when we + // see `]`. Once depth returns to zero, the remainder of the line is real code. + int index = 0; + while (index < line.Length && depth > 0) + { + if (line[index] == '[') depth++; + else if (line[index] == ']') depth--; + index++; + } + if (depth > 0) + return string.Empty; + return line[index..]; + } + private static CSharpPropertyMatchCandidate BuildCSharpPropertyMatchLine(string[] lines, string[] csharpMatchLines, int startLineIndex) { var matchLine = csharpMatchLines[startLineIndex]; diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 05653b9f92..2ee354605b 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -68,14 +68,14 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "references", - "Search indexed symbol references such as call sites. When `kind` is omitted, identical constructor `call` + `instantiate` rows at one physical site are collapsed. / 呼び出し箇所などのインデックス済みシンボル参照を検索。`kind` 未指定時は、同じ物理位置にある constructor の `call` + `instantiate` 重複行を集約する。", + "Search indexed symbol references such as call sites. When `kind` is omitted, all indexed reference kinds including metadata uses (`attribute` / `annotation`) stay visible, and identical constructor `call` + `instantiate` rows at one physical site are collapsed. / 呼び出し箇所などのインデックス済みシンボル参照を検索。`kind` 未指定時は metadata (`attribute` / `annotation`) も含む全 reference kind を表示したうえで、同じ物理位置にある constructor の `call` + `instantiate` 重複行を集約する。", new JsonObject { ["type"] = "object", ["properties"] = new JsonObject { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Referenced symbol name pattern to search for" }, - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (for example: call, instantiate, subscribe)" }, + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (call, instantiate, subscribe, attribute, annotation)" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line context payloads per result (default: 512)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 1, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, @@ -90,14 +90,14 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "callers", - "Find caller symbols that reference a callee. When `kind` is omitted, all indexed reference kinds stay visible while identical constructor `call` + `instantiate` rows at one physical site collapse. / 指定シンボルを参照している呼び出し元シンボルを探す。`kind` 未指定時は全 reference kind を表示したまま、同じ物理位置にある constructor の `call` + `instantiate` 重複行を集約する。", + "Find caller symbols that reference a callee. When `kind` is omitted, only call-graph kinds (`call`, `instantiate`, `subscribe`) are returned so metadata uses (`attribute` / `annotation`) do not pollute caller edges; identical constructor `call` + `instantiate` rows at one physical site also collapse. `callers` / `callees` are not a reliable path to metadata — an attribute / annotation row is attributed to its enclosing body-range symbol (for a class-level declaration, that is the class itself; for a file-level target such as `[assembly: ...]`, `container_name` is `null` and the row drops from these graph queries entirely). Use `references` with `kind: \"attribute\"` or `kind: \"annotation\"` for metadata enumeration. / 指定シンボルを参照している呼び出し元シンボルを探す。`kind` 未指定時は call-graph 種別 (`call` / `instantiate` / `subscribe`) のみを返し、metadata 使用 (`attribute` / `annotation`) が phantom caller edge として混入しないようにする。同じ物理位置にある constructor の `call` + `instantiate` 重複行も集約する。metadata 行の container は注釈対象そのものではなく body-range 上の外側シンボル(クラス直下宣言ならクラス、ファイルレベル target なら `null`)になるため、`callers` / `callees` は metadata 列挙に向かない。Metadata の参照列挙は `references --kind attribute|annotation` / MCP `references` を使う。", new JsonObject { ["type"] = "object", ["properties"] = new JsonObject { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Callee symbol name pattern to search for" }, - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (for example: call, instantiate, subscribe)" }, + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by call-graph reference kind (call, instantiate, subscribe). Metadata kinds (attribute, annotation) are rejected here; use `references` for metadata enumeration." }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, @@ -111,14 +111,14 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "callees", - "Find callees used by a caller/container symbol. When `kind` is omitted, all indexed reference kinds stay visible while identical constructor `call` + `instantiate` rows at one physical site collapse. / 呼び出し元シンボルが使っている呼び出し先を探す。`kind` 未指定時は全 reference kind を表示したまま、同じ物理位置にある constructor の `call` + `instantiate` 重複行を集約する。", + "Find callees used by a caller/container symbol. When `kind` is omitted, only call-graph kinds (`call`, `instantiate`, `subscribe`) are returned so metadata uses (`attribute` / `annotation`) do not pollute callee edges; identical constructor `call` + `instantiate` rows at one physical site also collapse. `callees` is not a reliable path to metadata — the container assigned to an attribute / annotation row is the enclosing body-range symbol, not the annotated declaration, so `callees Method1 --kind attribute` does not return the attributes on `Method1`. Use `references` with `kind: \"attribute\"` or `kind: \"annotation\"` for metadata enumeration. / 呼び出し元シンボルが使っている呼び出し先を探す。`kind` 未指定時は call-graph 種別 (`call` / `instantiate` / `subscribe`) のみを返し、metadata 使用 (`attribute` / `annotation`) が phantom callee edge として混入しないようにする。同じ物理位置にある constructor の `call` + `instantiate` 重複行も集約する。metadata 行の container は注釈対象自身ではなく body-range 上の外側シンボルになるため、`callees` で `Method1 --kind attribute` を引いても `Method1` に付いた属性は返らない。Metadata の列挙は `references --kind attribute|annotation` / MCP `references` を使う。", new JsonObject { ["type"] = "object", ["properties"] = new JsonObject { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Caller/container symbol name pattern to search for" }, - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (for example: call, instantiate, subscribe)" }, + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by call-graph reference kind (call, instantiate, subscribe). Metadata kinds (attribute, annotation) are rejected here; use `references` for metadata enumeration." }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 54d9a9d7b0..1598d46a58 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -90,6 +90,21 @@ private static void AddExactZeroHint(JsonObject payload, ExactZeroHintResult? ex /// private static int ClampLimit(int limit) => Math.Clamp(limit, 1, MaxLimit); + /// + /// Return true when the requested reference kind is a metadata kind (`attribute` / + /// `annotation`) — these are valid on the `references` tool but must be rejected on + /// `callers` / `callees`, whose data model cannot answer metadata questions correctly + /// (metadata rows are attributed to the enclosing body-range symbol rather than the + /// annotated target, so file-level targets drop entirely and method-level metadata + /// appears under the enclosing class). + /// `references` では有効だが `callers` / `callees` では構造的に誤答するため弾くべき + /// metadata kind (`attribute` / `annotation`) かを返す。metadata 行は注釈対象ではなく + /// body-range 上の外側シンボルに帰属するため、`callers` / `callees` はこの kind に + /// 正しく答えられない。 + /// + private static bool IsMetadataReferenceKind(string? kind) => + kind == "attribute" || kind == "annotation"; + private JsonNode? TryGetValidatedMaxLineWidth(JsonNode? id, JsonNode? args, out int maxLineWidth, string propertyName = "maxLineWidth") { var maxLineWidthValue = args?[propertyName]?.GetValue(); @@ -537,6 +552,8 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, $"Query too long (max {MaxQueryLength} characters)"); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); + if (IsMetadataReferenceKind(kind)) + return CreateToolErrorResponse(id, $"'kind: {kind}' is not supported on 'callers'. Metadata references are attributed to the enclosing body-range symbol, so `callers` cannot return accurate rows for kind '{kind}'. Use the 'references' tool with kind '{kind}' for metadata enumeration."); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); var pathPatterns = ReadPathList(args, "path"); @@ -596,6 +613,8 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, $"Query too long (max {MaxQueryLength} characters)"); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); + if (IsMetadataReferenceKind(kind)) + return CreateToolErrorResponse(id, $"'kind: {kind}' is not supported on 'callees'. Metadata references are attributed to the enclosing body-range symbol, so `callees` cannot return accurate rows for kind '{kind}'. Use the 'references' tool with kind '{kind}' for metadata enumeration."); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); var pathPatterns = ReadPathList(args, "path"); diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index cc1342b297..69bff5c27d 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -40,7 +40,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx find --path ", output); Assert.Contains("--exact-substring Search only: case-sensitive exact substring (no FTS5)", output); Assert.Contains("--exact-name symbols/definition/references/callers/callees/inspect: NFKC + Unicode CaseFold exact name match", output); - Assert.Contains("--kind definition/symbols/hotspots/unused: symbol kind; references/callers/callees: reference kind (call/instantiate/subscribe); validate: issue kind", output); + Assert.Contains("--kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind", output); Assert.Contains("--count Count only; search/definition/references/callers/callees/symbols/files/find/unused ignore --limit, impact/hotspots still use visible page counts", output); Assert.Contains("--commits [id ...] Update only files changed in the specified git commits (preferred after commits)", output); Assert.Contains("--files [path ...] Update only the specified files; old rename/delete paths are not purged unless also listed", output); diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 5119604213..566760b53c 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -2802,6 +2802,1349 @@ def Run(): Assert.DoesNotContain("foo.py", dependency.TargetPath, StringComparison.Ordinal); } + [Fact] + public void GetFileDependencies_IncludesMetadataReferencesAsCompileTimeDependencies() + { + // issue #293 follow-up: the attribute class `JsonConverter` is referenced + // both as a runtime `new JsonConverter(...)` call AND as compile-time + // attribute metadata `[JsonConverter(...)]`. Renaming or removing the + // class breaks both sites, so `cdidx deps` MUST surface both edges as + // real file-level dependencies. (`callers` / `callees` stay call-graph- + // only and reject `--kind attribute|annotation` separately at the CLI / + // MCP boundary — that is a different contract.) + // issue #293 補足: attribute クラス `JsonConverter` は runtime の + // `new JsonConverter(...)` としても、compile-time の `[JsonConverter(...)]` + // 属性 metadata としても参照される。クラスを rename / 削除すれば両方の + // サイトが壊れるため、`cdidx deps` は両方のエッジをファイル単位の本物の + // 依存として出す必要がある。(`callers` / `callees` は call-graph 専用で、 + // metadata 種別は CLI / MCP boundary 側で別途拒否する) + InsertIndexedFile("src/JsonConverterAttribute.cs", "csharp", + """ + using System; + + [AttributeUsage(AttributeTargets.Class)] + public class JsonConverter : Attribute + { + public Type ConverterType { get; } + public JsonConverter(Type converterType) => ConverterType = converterType; + } + """); + // Metadata-only usage — attribute form. Compile-time dependency: renaming + // `JsonConverter` breaks this file at build time. + // metadata-only の利用 (attribute 形式)。compile-time 依存: + // `JsonConverter` を rename すればこのファイルも build-time で壊れる。 + InsertIndexedFile("src/Serializer.cs", "csharp", + """ + [JsonConverter(typeof(int))] + public class SerializerConfig + { + } + """); + // Runtime dependency — `new JsonConverter(...)` is a `call` / `instantiate` edge. + // 実行時の依存 — `new JsonConverter(...)` は `call` / `instantiate` 種別の edge。 + InsertIndexedFile("src/Caller.cs", "csharp", + """ + public class Caller + { + public void Do() + { + var c = new JsonConverter(typeof(int)); + } + } + """); + + var dependencies = _reader.GetFileDependencies(limit: 10, lang: "csharp"); + + // Both Caller.cs (runtime `instantiate`) and Serializer.cs (attribute + // metadata) must appear as dependencies of JsonConverterAttribute.cs. + // Caller.cs (runtime `instantiate`) と Serializer.cs (attribute metadata) + // の両方が JsonConverterAttribute.cs への依存として現れる。 + Assert.Equal(2, dependencies.Count); + Assert.Contains(dependencies, d => d.SourcePath == "src/Caller.cs" && d.TargetPath == "src/JsonConverterAttribute.cs"); + Assert.Contains(dependencies, d => d.SourcePath == "src/Serializer.cs" && d.TargetPath == "src/JsonConverterAttribute.cs"); + } + + [Fact] + public void GetFileDependencies_MatchesCSharpAttributeSuffixConvention() + { + // issue #293 follow-up: C# convention — a class `FooAttribute` is used in + // source as `[Foo]`, so the reference site is stored with symbol_name `Foo`. + // `deps` must canonicalize these so the attribute class file is still + // recognized as a dependency target for pure-attribute consumers. + // issue #293 補足: C# の規約では、クラス `FooAttribute` はソース中で `[Foo]` + // として使われるため、参照サイトは symbol_name `Foo` として保存される。 + // `deps` はこれを正規化し、attribute 専用の consumer でも attribute クラスの + // ファイルを依存 target として認識できるようにする。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + using System; + + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + """); + // Idiomatic `[MyAudit]` usage — symbol_name recorded as `MyAudit` but target + // class is `MyAuditAttribute`. + // 慣用的な `[MyAudit]` 利用 — symbol_name は `MyAudit` として記録されるが + // target クラスは `MyAuditAttribute`。 + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var dependencies = _reader.GetFileDependencies(limit: 10, lang: "csharp"); + + Assert.Contains(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencies_CSharpGenericNoArgAttribute_StillIndexedAndResolvesToAttributeClass() + { + // issue #293 round-15 follow-up: generic no-arg C# attributes like + // `[MyAudit]` and multi-line `[\n MyAttr\n]` must still be + // indexed as `attribute` references so `deps` can route them through + // the suffix-alias synthesizer to the real attribute class file. + // Before the regex was widened these forms fell through both CallRegex + // (no `(`) and the no-arg regex (generic `<...>` after the name broke + // the `(?=[\],]|$)` anchor), producing zero edges. + // issue #293 round-15 補足: `[MyAudit]` や複数行の `[\n MyAttr\n]` + // のようなジェネリック引数なし属性も `attribute` として取り込まれ、 + // suffix alias を経由して実属性クラスへの依存エッジに正規化される + // こと。正規表現の拡張前は両 regex とも拾えず、エッジが 0 件だった。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + using System; + + public sealed class MyAuditAttribute : Attribute + { + } + """); + InsertIndexedFile("src/MyAttrAttribute.cs", "csharp", + """ + using System; + + public sealed class MyAttrAttribute : Attribute + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + [ + MyAttr + ] + public class Svc + { + } + """); + + var dependencies = _reader.GetFileDependencies(limit: 20, lang: "csharp"); + + Assert.Contains(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/MyAuditAttribute.cs"); + Assert.Contains(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/MyAttrAttribute.cs"); + } + + [Fact] + public void GetFileDependencies_CSharpGenericNoArgAttribute_AssemblyTarget_IsIndexed() + { + // issue #293 round-15 follow-up: `[assembly: MyAttr]` — assembly + // targeted generic no-arg attribute must also reach the attribute class. + // issue #293 round-15 補足: `[assembly: MyAttr]` のような + // assembly targeted ジェネリック引数なし属性も同様にインデックスされ、 + // attribute クラスに解決されること。 + InsertIndexedFile("src/MyAttrAttribute.cs", "csharp", + """ + using System; + + public sealed class MyAttrAttribute : Attribute + { + } + """); + InsertIndexedFile("src/AssemblyInfo.cs", "csharp", + """ + [assembly: MyAttr] + """); + + var dependencies = _reader.GetFileDependencies(limit: 20, lang: "csharp"); + + Assert.Contains(dependencies, d => d.SourcePath == "src/AssemblyInfo.cs" && d.TargetPath == "src/MyAttrAttribute.cs"); + } + + [Fact] + public void GetFileDependencies_CSharpAttributeRawDoesNotLeakToBareNameClass_WhenSuffixTargetExists() + { + // issue #293 follow-up: `[MyAudit]` in C# is stored as symbol_name='MyAudit'. + // When both `class MyAudit` (plain class) and `class MyAuditAttribute` + // (the real attribute target) exist, the metadata edge must resolve only + // to `MyAuditAttribute` via the synthetic suffix alias. Keeping the raw + // bare-name edge would over-report: `[MyAudit]` would falsely depend on + // the unrelated plain `class MyAudit` file. + // issue #293 補足: C# の `[MyAudit]` は symbol_name='MyAudit' で保存される。 + // `class MyAudit` (plain) と `class MyAuditAttribute` (本物の attribute) + // が両方あるとき、metadata エッジは synthetic suffix alias 経由で + // `MyAuditAttribute` だけに解決されるべき。raw の bare-name エッジを + // 残すと、`[MyAudit]` が無関係な plain `class MyAudit` のファイルにも + // 誤って依存してしまう。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + using System; + + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + """); + InsertIndexedFile("src/PlainMyAudit.cs", "csharp", + """ + public class MyAudit + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var dependencies = _reader.GetFileDependencies(limit: 10, lang: "csharp"); + + // Only MyAuditAttribute.cs should be a dependency target for Svc.cs; + // PlainMyAudit.cs must not appear. + // Svc.cs の依存先は MyAuditAttribute.cs のみで、PlainMyAudit.cs は + // 出現してはならない。 + Assert.Contains(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/MyAuditAttribute.cs"); + Assert.DoesNotContain(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/PlainMyAudit.cs"); + } + + [Fact] + public void GetFileDependencies_CSharpAttributeDoesNotLeakToSameNameMethodOrProperty() + { + // issue #293 follow-up: `[MyAuditAttribute]` (fully qualified) must only + // match a class-like attribute target. A method / property named + // `MyAuditAttribute` in an unrelated file must never show up as a deps + // edge from the metadata reference. Non-metadata call-graph edges keep + // their previous behavior (they can still resolve to any symbol kind). + // issue #293 補足: `[MyAuditAttribute]` (完全形) は class 系の attribute + // target にしか一致してはならない。別ファイルの同名メソッド/プロパティ + // `MyAuditAttribute` が metadata 参照の deps エッジに現れてはいけない。 + // 非 metadata の call-graph エッジは従来どおり任意の kind に解決できる。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + using System; + + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + """); + InsertIndexedFile("src/Helpers.cs", "csharp", + """ + public class Helpers + { + public void MyAuditAttribute() + { + } + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAuditAttribute] + public class Svc + { + } + """); + + var dependencies = _reader.GetFileDependencies(limit: 10, lang: "csharp"); + + Assert.DoesNotContain(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/Helpers.cs"); + } + + [Fact] + public void GetFileDependencies_CSharpAttributeAmbiguityCountsSameFileDuplicateClassDefinitions() + { + // issue #293 follow-up: when a single source file defines TWO same-named + // class-like attribute targets under different namespaces (idiomatic C# + // with multiple `namespace { ... }` blocks in one .cs file), the metadata + // edge must be dropped as ambiguous just like the multi-file case. A + // path-level count (COUNT DISTINCT target_path) would see `count = 1` + // because both definitions live in the same file, so the previous + // target_ambiguity CTE falsely treated the target as unambiguous. The + // rewritten CTE joins back through files + symbols so it counts at + // symbol-identity level and correctly sees `count = 2`. + // issue #293 補足: 1 つの .cs ファイルに別名前空間で同名 class-like が 2 つ + // 定義されている場合 (C# でよくある `namespace { ... }` 複数ブロック形式) + // でも、複数ファイルのときと同様に metadata edge は ambiguous として落とす + // 必要がある。path 単位 (COUNT DISTINCT target_path) だと両方が同じ file に + // あるため count=1 となり、従来の target_ambiguity では誤って一意扱いされた。 + // 書き直した CTE は files + symbols に JOIN し直すため、symbol identity 単位 + // で count=2 を正しく検出する。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + using System; + + namespace A + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + } + + namespace B + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var dependencies = _reader.GetFileDependencies(limit: 10, lang: "csharp"); + + // Even though both MyAuditAttribute definitions live in the same file, the + // metadata reference is still ambiguous and must not produce a deps edge. + // 同じファイル内にある 2 つの MyAuditAttribute 定義でも metadata 参照は + // 曖昧扱いのため、deps edge を出してはならない。 + Assert.DoesNotContain(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencies_CSharpAttributeDoesNotFanOutWhenMultipleSameNameAttributeClasses() + { + // issue #293 follow-up: if multiple same-named attribute classes exist + // (e.g. two `MyAuditAttribute` classes in separate namespaces/files), + // a metadata reference `[MyAudit]` must not fan out to BOTH files. We + // cannot statically resolve which one the C# compiler picks without + // namespace / using analysis, so we drop the ambiguous metadata edge + // and let `impact` / `references` surface both candidates to the user. + // issue #293 補足: 同名 attribute クラスが複数ある場合 (例: 別名前空間/別 + // ファイルに 2 つの `MyAuditAttribute` がある場合)、metadata 参照 + // `[MyAudit]` を両方に fan-out させない。cdidx は namespace / using を + // 解析しないため正しい解決ができず、あいまいな metadata エッジは落として + // 両候補は `impact` / `references` 経由でユーザーに示す。 + InsertIndexedFile("src/A/MyAuditAttribute.cs", "csharp", + """ + using System; + + namespace A + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + } + """); + InsertIndexedFile("src/B/MyAuditAttribute.cs", "csharp", + """ + using System; + + namespace B + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var dependencies = _reader.GetFileDependencies(limit: 10, lang: "csharp"); + + // Neither fan-out edge should exist; the metadata reference is ambiguous. + // あいまいな metadata 参照はどちらの fan-out エッジも出してはならない。 + Assert.DoesNotContain(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/A/MyAuditAttribute.cs"); + Assert.DoesNotContain(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/B/MyAuditAttribute.cs"); + } + + [Fact] + public void SearchReferences_MatchesCSharpAttributeSuffixConvention_Substring() + { + // issue #293 follow-up: `references MyAuditAttribute` (substring mode) must + // find `[MyAudit]` call sites so `references` / `inspect` / `analyze_symbol` + // stay consistent with `deps` / `impact` canonicalization. + // issue #293 補足: `references MyAuditAttribute`(部分一致モード)が `[MyAudit]` + // 参照サイトを見つけられなければならず、`references` / `inspect` / + // `analyze_symbol` が `deps` / `impact` の正規化と整合する必要がある。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + using System; + + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var results = _reader.SearchReferences("MyAuditAttribute", lang: "csharp"); + + Assert.Contains(results, r => r.Path == "src/Svc.cs" && r.ReferenceKind == "attribute"); + } + + [Fact] + public void SearchReferences_MatchesCSharpAttributeSuffixConvention_Exact() + { + // Same scenario under `--exact` — the suffix alias must be applied even when + // exact-name matching is requested, otherwise `references MyAuditAttribute + // --exact` loses the attribute call site. + // `--exact` 指定下でも同様 — exact match の場合でも suffix alias を適用しない + // と、`references MyAuditAttribute --exact` は attribute 参照サイトを取りこぼす。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + using System; + + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var results = _reader.SearchReferences("MyAuditAttribute", lang: "csharp", exact: true); + + Assert.Contains(results, r => r.Path == "src/Svc.cs" && r.ReferenceKind == "attribute"); + } + + [Fact] + public void SearchReferences_CSharpAttributeSuffixAliasDoesNotBleedToOtherLanguages() + { + // Alias must be C# only — a Java `@MyAudit(...)` annotation using the + // suffix convention is not part of the Java ecosystem, so querying for + // `MyAuditAttribute` under Java scope must not spuriously match `MyAudit`. + // alias は C# 限定 — Java の `@MyAudit(...)` annotation は suffix 規約を使わない + // ので、Java スコープで `MyAuditAttribute` を指定したときに `MyAudit` に + // 誤って match してはならない。 + InsertIndexedFile("src/Svc.java", "java", + """ + @MyAudit + public class Svc { + } + """); + + var results = _reader.SearchReferences("MyAuditAttribute", lang: "java"); + + Assert.Empty(results); + } + + [Fact] + public void SearchReferences_CSharpAttributeSuffixAlias_NotAppliedToCallKind() + { + // Adversarial review #7 follow-up: the suffix alias must NOT bleed into + // `--kind call` queries. `references FooAttribute --kind call --lang csharp` + // must not match a plain `Foo()` call — that would be a false positive. + // adversarial review #7 補足: suffix alias を `--kind call` クエリに波及させない。 + // `references FooAttribute --kind call --lang csharp` が素の `Foo()` 呼び出しに + // 一致してはならない(誤一致になる)。 + InsertIndexedFile("src/Svc.cs", "csharp", + """ + public class Svc + { + public void Call() + { + MyAudit(); + } + } + """); + + var results = _reader.SearchReferences("MyAuditAttribute", lang: "csharp", referenceKind: "call"); + + Assert.DoesNotContain(results, r => r.SymbolName == "MyAudit"); + } + + [Fact] + public void SearchReferences_CSharpAttributeSuffixAlias_UnscopedLangStillLimitsToCSharpAttributeRows() + { + // When `--lang` is omitted, the alias must still only match C# attribute rows. + // A Java `@MyAudit(...)` or a bare `MyAudit()` call must not leak through. + // `--lang` を省略したときも、alias は C# の attribute 行にしか一致してはならない。 + // Java の `@MyAudit(...)` や素の `MyAudit()` 呼び出しが漏れてはならない。 + InsertIndexedFile("src/Svc.java", "java", + """ + @MyAudit + public class Svc { + } + """); + InsertIndexedFile("src/Caller.cs", "csharp", + """ + public class Caller + { + public void Go() + { + MyAudit(); + } + } + """); + InsertIndexedFile("src/Target.cs", "csharp", + """ + [MyAudit] + public class Target + { + } + """); + + var results = _reader.SearchReferences("MyAuditAttribute"); + + // Should include the C# attribute site on Target.cs … + Assert.Contains(results, r => r.Path == "src/Target.cs" && r.ReferenceKind == "attribute"); + // … but must NOT include the Java annotation nor the C# call row via alias. + Assert.DoesNotContain(results, r => r.Path == "src/Svc.java"); + Assert.DoesNotContain(results, r => r.Path == "src/Caller.cs" && r.ReferenceKind == "call"); + } + + [Fact] + public void SearchReferences_CSharpAttributeSuffixAlias_CaseInsensitiveQuery() + { + // The surrounding exact / substring paths are case-insensitive (folded or + // NOCASE), so the suffix-stripping step must also be case-insensitive — + // `references myauditattribute` / `MyAuditATTRIBUTE --exact` / etc. must + // still produce the `MyAudit` alias and reach the `[MyAudit]` site. + // 周辺の exact / substring 経路は case-insensitive(folded or NOCASE)なので、 + // suffix 除去も case-insensitive であるべき。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + using System; + + public sealed class MyAuditAttribute : Attribute + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var lowercaseResults = _reader.SearchReferences("myauditattribute", lang: "csharp"); + Assert.Contains(lowercaseResults, r => r.Path == "src/Svc.cs" && r.ReferenceKind == "attribute"); + + var mixedCaseExactResults = _reader.SearchReferences("MyAuditATTRIBUTE", lang: "csharp", exact: true); + Assert.Contains(mixedCaseExactResults, r => r.Path == "src/Svc.cs" && r.ReferenceKind == "attribute"); + } + + [Fact] + public void GetFileDependencies_CSharpAttributeAliasOnlyMatchesClassLikeTargets() + { + // issue #293 review: the C# attribute suffix alias UNION synthesizes a + // `FooAttribute` lookup key for `[Foo]` references. Without a kind guard the + // subsequent name-only join would spuriously attribute the consumer to any + // file that merely defines a function / property / variable also named + // `FooAttribute`. Only class-like target symbols should match synthetic alias + // rows. + // issue #293 レビュー指摘: `[Foo]` 用の alias UNION は `FooAttribute` という + // lookup key を合成するが、kind によるガードが無いと、偶然 `FooAttribute` + // という名前を持つ関数 / プロパティ / 変数を含むファイルにまで依存が張られて + // しまう。合成 alias 行は class 系の target にのみ一致すべき。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + using System; + + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + """); + // Unrelated file containing a function named `MyAuditAttribute` — not an + // attribute class, so `[MyAudit]` must not produce a dependency edge to it. + // 無関係なファイルに関数として `MyAuditAttribute` が居るケース。 + // `[MyAudit]` はこのファイルへの依存を作ってはいけない。 + InsertIndexedFile("src/Util.cs", "csharp", + """ + public static class Util + { + public static void MyAuditAttribute() + { + } + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var dependencies = _reader.GetFileDependencies(limit: 20, lang: "csharp"); + + Assert.Contains(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/MyAuditAttribute.cs"); + Assert.DoesNotContain(dependencies, d => d.SourcePath == "src/Svc.cs" && d.TargetPath == "src/Util.cs"); + } + + [Fact] + public void GetFileDependencyHints_SuppressesCSharpAttributeMetadataBypassOnAmbiguousTarget() + { + // issue #293 review: when two classes share the `MyAuditAttribute` name + // *within the active impact scope*, a `[MyAudit]` reference row only + // carries the short name and cannot be uniquely attributed to either + // target. In that ambiguous case the `impact` metadata evidence bypass + // must be skipped so rename / removal blast radius is not over-reported. + // issue #293 レビュー指摘: impact スコープ内で同名の `MyAuditAttribute` + // クラスが複数存在するとき、`[MyAudit]` 参照行は短縮名しか持たず、 + // どちらの target にも一意に紐付けられない。この曖昧なケースでは + // `impact` の metadata evidence bypass を行わず、rename / 削除の影響 + //範囲を過大報告しないようにする。 + InsertIndexedFile("src/A/Inner1/MyAuditAttribute.cs", "csharp", + """ + namespace A.Inner1; + + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/A/Inner2/MyAuditAttribute.cs", "csharp", + """ + namespace A.Inner2; + + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + // Pure attribute consumer in src/A/ — no structured type evidence exists for + // `MyAuditAttribute` other than the `[MyAudit]` use site itself. + // src/A/ に純粋な attribute consumer — `MyAuditAttribute` に対する構造化された + // 型証拠は `[MyAudit]` use site 以外には無い。 + InsertIndexedFile("src/A/Svc.cs", "csharp", + """ + namespace A; + + [MyAudit] + public class Svc + { + } + """); + + // Both ambiguous definitions are within the `src/A/` scope; without the + // ambiguity guard, the metadata bypass would fabricate a heuristic edge + // even though the `[MyAudit]` target is qualifier-ambiguous. + // src/A/ スコープ内に曖昧な定義が 2 件ある。ambiguity guard が無ければ、 + // `[MyAudit]` の target が qualifier 曖昧でも metadata bypass が heuristic + // エッジを作ってしまう。 + var result = _reader.AnalyzeImpact( + "MyAuditAttribute", + maxDepth: 3, + limit: 20, + lang: "csharp", + pathPatterns: new[] { "src/A/" }); + + Assert.DoesNotContain(result.FileImpacts, f => f.SourcePath == "src/A/Svc.cs"); + } + + [Fact] + public void GetFileDependencyHints_CSharpAttributeMetadataBypassAppliesWhenTargetUnambiguous() + { + // issue #293 review: the ambiguity guard must only fire when genuinely + // ambiguous. With a single class-like `MyAuditAttribute` definition the + // metadata bypass should still surface the `[MyAudit]` consumer as a + // file-level hint, preserving the legitimate pure-attribute consumer case. + // issue #293 レビュー指摘: ambiguity guard は本当に曖昧なときだけ発動すべき。 + // `MyAuditAttribute` の class 定義が 1 件しかない場合は従来通り metadata + // bypass で `[MyAudit]` consumer を file-level hint として出し、純粋な + // attribute consumer の正当な検出を保つ。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var result = _reader.AnalyzeImpact("MyAuditAttribute", maxDepth: 3, limit: 20, lang: "csharp"); + + Assert.Contains(result.FileImpacts, f => f.SourcePath == "src/Svc.cs" && f.TargetPath == "src/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencyHints_MetadataBypassAmbiguityGuard_CountsSameFileDuplicateDefinitions() + { + // issue #293 follow-up: the `impact` metadata bypass ambiguity guard must + // count class-like definitions at symbol-identity level rather than at path + // level. A single .cs file with two same-named `MyAuditAttribute` class + // declarations under different namespaces is still ambiguous — metadata + // reference rows only keep the short name `MyAudit` and cannot resolve + // between `A.MyAuditAttribute` and `B.MyAuditAttribute`. Previously the + // guard counted `SELECT DISTINCT f.path` so both definitions collapsed to + // 1 and the bypass falsely fired, mis-attributing `[MyAudit]` consumers to + // the impact of a specific target when the true resolution is unknown. + // issue #293 補足: `impact` の metadata bypass 曖昧性ガードは、path 単位 + // ではなく symbol identity 単位で class-like 定義を数える必要がある。1 つの + // .cs ファイル内に別名前空間で `MyAuditAttribute` が 2 つ定義されていても、 + // metadata 参照は短縮名 `MyAudit` しか持たず `A.MyAuditAttribute` と + // `B.MyAuditAttribute` を区別できないため依然として曖昧。従来は + // `SELECT DISTINCT f.path` で数えていたため両定義が 1 に潰れ、bypass が + // 誤って発動し `[MyAudit]` consumer を特定 target の影響範囲へ誤帰属させていた。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + using System; + + namespace A + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + } + + namespace B + { + [AttributeUsage(AttributeTargets.Class)] + public sealed class MyAuditAttribute : Attribute + { + } + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var result = _reader.AnalyzeImpact("MyAuditAttribute", maxDepth: 3, limit: 20, lang: "csharp"); + + // Two same-named class-like definitions in one file still make the target + // ambiguous, so the `[MyAudit]` consumer must not surface as a file-level + // impact hint — the metadata evidence bypass should fall through to the + // normal structured-evidence check, which `[MyAudit]`-only consumers fail. + // 同じファイル内の 2 つの同名 class-like 定義でも target は曖昧なので、 + // `[MyAudit]` consumer は file-level impact hint に現れてはいけない。 + // metadata evidence bypass は通常の structured-evidence 判定へフォール + // スルーし、pure `[MyAudit]` consumer はそこで落ちる。 + Assert.DoesNotContain(result.FileImpacts, f => f.SourcePath == "src/Svc.cs" && f.TargetPath == "src/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencyHints_CSharpAttributeSuffixAlias_DoesNotLeakToSameFileSiblings() + { + // issue #293 round-12 follow-up: the C# `Attribute` suffix alias used by + // ResolveImpactFallbackNames must only be applied to the resolved + // definition's own name. If it were applied to every same-file fallback + // name (e.g. a nested `BarAttribute` inside the file that defines + // `FooAttribute`), `impact FooAttribute` would falsely claim `[Bar]` use + // sites as its own blast radius. + // issue #293 round-12 追加: ResolveImpactFallbackNames の C# `Attribute` + // suffix 別名は、解決済み定義自身の名前にだけ適用すべき。same-file + // fallback 名全体(例: `FooAttribute` と同一ファイルに nested で存在する + // `BarAttribute`)にまで strip を適用すると、`impact FooAttribute` が + // `[Bar]` 利用を自身の影響範囲として誤報告してしまう。 + InsertIndexedFile("src/FooAttribute.cs", "csharp", + """ + public sealed class FooAttribute : System.Attribute + { + public sealed class BarAttribute : System.Attribute + { + } + } + """); + // A separate file uses `[Bar]` — that must NOT show up in + // `impact FooAttribute` because it references `BarAttribute`, not + // `FooAttribute`. + // 別ファイルで `[Bar]` を使う — これは `BarAttribute` の参照であり、 + // `FooAttribute` の `impact` には出てはならない。 + InsertIndexedFile("src/UseBar.cs", "csharp", + """ + [Bar] + public class UseBar + { + } + """); + + var result = _reader.AnalyzeImpact("FooAttribute", maxDepth: 3, limit: 20, lang: "csharp"); + + Assert.DoesNotContain(result.FileImpacts, f => f.SourcePath == "src/UseBar.cs"); + } + + [Fact] + public void GetFileDependencyHints_MetadataBypassAmbiguityGuard_RespectsLangScope() + { + // issue #293 round-11 follow-up: the ambiguity guard must honor the active + // `--lang` scope. A same-named class in an unrelated language must not + // suppress the C# metadata bypass because attribute reference rows are + // already language-qualified through the graph-supported `f.lang = 'csharp'` + // join on the reference side. + // issue #293 round-11 追加: ambiguity guard は active な `--lang` スコープを + // 尊重すべき。別言語に同名クラスが存在しても C# の metadata bypass を + // 潰してはならない — 参照側の join で既に言語修飾されているため、曖昧性は + // 言語スコープ内でのみ判定する。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + // Unrelated Java class / annotation sharing the unqualified name — must not + // affect the C#-only impact query. + // 無関係な Java 側の同名クラス / アノテーション — C# 限定の impact クエリに + // 影響してはならない。 + InsertIndexedFile("src/java/MyAuditAttribute.java", "java", + """ + package pkg; + + public @interface MyAuditAttribute { + } + """); + + var result = _reader.AnalyzeImpact("MyAuditAttribute", maxDepth: 3, limit: 20, lang: "csharp"); + + Assert.Contains(result.FileImpacts, f => f.SourcePath == "src/Svc.cs" && f.TargetPath == "src/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencyHints_MetadataBypassAmbiguityGuard_RespectsPathScope() + { + // issue #293 round-11 follow-up: ambiguity guard must honor `--path` + // scoping. A same-named class outside the requested path subtree should + // not suppress the bypass inside that subtree. + // issue #293 round-11 追加: ambiguity guard は `--path` スコープを尊重すべき。 + // 要求した path サブツリー外にある同名クラスが、サブツリー内の bypass を + // 潰してはならない。 + InsertIndexedFile("src/A/MyAuditAttribute.cs", "csharp", + """ + namespace A; + + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/A/Svc.cs", "csharp", + """ + namespace A; + + [MyAudit] + public class Svc + { + } + """); + // Out-of-scope same-named definition in src/B/ — must not affect the + // src/A/-scoped impact query. + // スコープ外 src/B/ の同名定義 — src/A/ 限定の impact クエリに影響してはならない。 + InsertIndexedFile("src/B/MyAuditAttribute.cs", "csharp", + """ + namespace B; + + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + + var result = _reader.AnalyzeImpact( + "MyAuditAttribute", + maxDepth: 3, + limit: 20, + lang: "csharp", + pathPatterns: new[] { "src/A/" }); + + Assert.Contains(result.FileImpacts, f => f.SourcePath == "src/A/Svc.cs" && f.TargetPath == "src/A/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencyHints_MetadataBypassAmbiguityGuard_CliPathPatternEscaping_SuppressesWhenInScopeIsAmbiguous() + { + // issue #293 round-15 follow-up: path / exclude-path parameters must be + // wrapped with `%...%` and routed through EscapeLikeQuery so the LIKE + // predicate accepts CLI-style prefixes like `src/A/`. Without the wrap + // the ambiguity count would underflow to 1 (unambiguous), and the + // metadata bypass would falsely fire even though two MyAuditAttribute + // classes exist side-by-side in the requested subtree. + // issue #293 round-15 補足: path / exclude-path のバインドは他の reader + // 経路と同じ `%...%` + EscapeLikeQuery に揃える必要がある。生値で渡すと + // `src/A/` のような CLI 形では LIKE が一致せず、要求したサブツリーに + // 同名 MyAuditAttribute が 2 件存在しても曖昧性カウントが 1 に落ち、 + // 本来抑止すべき metadata bypass が誤発火してしまう。 + InsertIndexedFile("src/A/Inner1/MyAuditAttribute.cs", "csharp", + """ + namespace A.Inner1; + + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/A/Inner2/MyAuditAttribute.cs", "csharp", + """ + namespace A.Inner2; + + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/A/Svc.cs", "csharp", + """ + namespace A; + + [MyAudit] + public class Svc + { + } + """); + + var result = _reader.AnalyzeImpact( + "MyAuditAttribute", + maxDepth: 3, + limit: 20, + lang: "csharp", + pathPatterns: new[] { "src/A/" }); + + Assert.DoesNotContain(result.FileImpacts, f => + f.SourcePath == "src/A/Svc.cs" && + (f.TargetPath == "src/A/Inner1/MyAuditAttribute.cs" || f.TargetPath == "src/A/Inner2/MyAuditAttribute.cs")); + } + + [Fact] + public void GetFileDependencies_CSharp_PlainClassWithAttributeSuffixName_DoesNotCountAsAmbiguity() + { + // issue #293 round-16: same metadata-eligibility filter must apply to + // the `deps` command. target_files.has_metadata_target_kind and the + // target_ambiguity JOIN both require C# class-like targets to inherit + // from an Attribute-suffixed base, so a plain `MyAuditAttribute` + // cannot ambiguate the edge from `Svc.cs` to the real attribute class. + // issue #293 round-16: 同じ適格性フィルタを deps にも適用する。 + // target_files.has_metadata_target_kind と target_ambiguity JOIN は + // C# では Attribute suffix 継承を要求するため、plain `MyAuditAttribute` が + // 存在しても実 attribute クラスへのエッジは残る。 + InsertIndexedFile("src/Real/MyAuditAttribute.cs", "csharp", + """ + namespace Real; + + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/Unrelated/MyAuditAttribute.cs", "csharp", + """ + namespace Unrelated; + + public sealed class MyAuditAttribute + { + } + """); + InsertIndexedFile("src/Real/Svc.cs", "csharp", + """ + namespace Real; + + [MyAudit] + public class Svc + { + } + """); + + var deps = _reader.GetFileDependencies( + limit: 50, + lang: "csharp"); + + Assert.Contains(deps, d => + d.SourcePath == "src/Real/Svc.cs" && + d.TargetPath == "src/Real/MyAuditAttribute.cs"); + Assert.DoesNotContain(deps, d => + d.SourcePath == "src/Real/Svc.cs" && + d.TargetPath == "src/Unrelated/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencies_CSharpNestedGenericNoArgAttribute_ResolvesToAttributeClass() + { + // issue #293 round-16: the no-arg C# attribute regex must handle + // nested generic type arguments (e.g. `[MyAttr>]`). + // Previously the inner `<...>` segment excluded `>`, which broke on the + // first inner `>` and classified the reference as a call. + // issue #293 round-16: 引数なし C# 属性 regex が + // `[MyAttr>]` のようなネスト generic を + // 扱えること。以前は内側の `<...>` セグメントが `>` を除外していて、 + // 最初の内側 `>` で崩れて call として誤分類されていた。 + InsertIndexedFile("src/MyAttrAttribute.cs", "csharp", + """ + using System; + using System.Collections.Generic; + + public sealed class MyAttrAttribute : Attribute + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + using System.Collections.Generic; + + [MyAttr>] + public class Svc + { + } + """); + + var deps = _reader.GetFileDependencies( + limit: 50, + lang: "csharp"); + + Assert.Contains(deps, d => + d.SourcePath == "src/Svc.cs" && + d.TargetPath == "src/MyAttrAttribute.cs"); + } + + [Fact] + public void GetFileDependencies_CSharp_IndirectAttributeInheritance_ResolvesAsMetadataTarget() + { + // issue #293 round-17: the metadata-eligibility filter must not require + // the immediate base class to end in `Attribute`. Indirect inheritance + // like `class MyAuditAttribute : BaseAudit` where `BaseAudit : Attribute` + // is a valid `[MyAudit]` target at compile time. The previous strict + // pattern (`signature LIKE '%: %Attribute%'`) wrongly excluded the + // indirectly-derived class and dropped the deps edge. The loosened + // pattern (`signature LIKE '%: %'`) accepts any class with an + // inheritance clause, which is the best portable approximation since + // SQL cannot resolve base types transitively. + // issue #293 round-17: metadata 適格性フィルタは直接基底が + // `Attribute` で終わることを要求してはならない。 + // `class MyAuditAttribute : BaseAudit` で `BaseAudit : Attribute` の + // ような間接継承も `[MyAudit]` の有効な target である。以前の + // 厳格パターンは間接継承を弾いて deps エッジを落としていた。 + // 緩和パターンは「継承節を持つ class」を近似として採用する。 + InsertIndexedFile("src/BaseAudit.cs", "csharp", + """ + namespace App; + + public abstract class BaseAudit : System.Attribute + { + } + """); + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + namespace App; + + public sealed class MyAuditAttribute : BaseAudit + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + namespace App; + + [MyAudit] + public class Svc + { + } + """); + + var deps = _reader.GetFileDependencies( + limit: 50, + lang: "csharp"); + + Assert.Contains(deps, d => + d.SourcePath == "src/Svc.cs" && + d.TargetPath == "src/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencies_JavaScriptFunctionDecorator_ResolvesAsDependency() + { + // issue #293 round-18: JavaScript/TypeScript decorators legitimately target + // factory `function`s (e.g. `function sealed(target) { ... }`), not only + // class-like definitions. The metadata-target predicate must accept + // `function` for JS/TS or decorator edges to a function target are dropped + // from `deps`. + // issue #293 round-18: JS/TS decorator は `function sealed(target){...}` のような + // factory 関数も正当な target となる。JS/TS では `function` を metadata target の + // 対象 kind として許可しないと、function を対象とする decorator edge が deps から欠落する。 + InsertIndexedFile("src/decorators.js", "javascript", + """ + export function sealed(target) { + Object.freeze(target); + } + """); + InsertIndexedFile("src/model.js", "javascript", + """ + import { sealed } from './decorators.js'; + + @sealed + class Foo { + } + """); + + var deps = _reader.GetFileDependencies( + limit: 50, + lang: "javascript"); + + Assert.Contains(deps, d => + d.SourcePath == "src/model.js" && + d.TargetPath == "src/decorators.js"); + } + + [Fact] + public void GetFileDependencies_CSharp_LegacyDbWithNullSignature_StillResolvesAttributeEdge() + { + // issue #293 round-19: the metadata-target signature clause must degrade + // gracefully when the `symbols.signature` column exists but individual + // rows carry NULL values — the common shape of a DB whose schema was + // migrated in place (`TryMigrateForRead`) without reindexing. Requiring + // `signature LIKE '%: %'` would silently drop the real + // `[MyAudit]` → `class MyAuditAttribute : System.Attribute` edge there, + // so the clause must treat NULL signature as eligible (equivalent to the + // column-missing `1 = 1` fallback). + // issue #293 round-19: metadata-target の signature 句は、列は存在するが + // row の値が NULL の legacy-migration DB でも degrade する必要がある。 + // LIKE を強要すると本物の `[MyAudit]` edge が silent に落ちる。 + // 列欠落時の `1 = 1` fallback と同じく NULL も eligible にする。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + // Simulate the partial-migration shape: signature column is present but the + // C# class row has a NULL signature, as if the schema were upgraded in place + // without re-running extraction. + // partial-migration の形を再現: signature 列はあるが C# class 行の signature が + // NULL の状態 — その場 schema 移行後に再抽出していない DB と同じ。 + using (var cmd = _db.Connection.CreateCommand()) + { + cmd.CommandText = "UPDATE symbols SET signature = NULL WHERE name = 'MyAuditAttribute' AND kind = 'class'"; + cmd.ExecuteNonQuery(); + } + + var deps = _reader.GetFileDependencies( + limit: 50, + lang: "csharp"); + + Assert.Contains(deps, d => + d.SourcePath == "src/Svc.cs" && + d.TargetPath == "src/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencies_CSharp_LegacyDbWithNullSignature_NonAttributeName_DoesNotBlockMetadataEdge() + { + // issue #293 round-20: the NULL-signature fallback must not treat + // arbitrary classes as metadata targets. Before this round the clause + // accepted `signature IS NULL` for every C# `class`, so on a legacy-migration + // DB a non-attribute class named `HelperClient` could share a name with an + // attribute-applied site and silently inject false ambiguity. The tightened + // fallback requires the canonical C# attribute naming convention + // (`name LIKE '%Attribute'`), so a NULL-signature `HelperClient` is no + // longer counted and the real `[MyAudit]` edge to `MyAuditAttribute` + // survives even when both rows have NULL signatures. + // issue #293 round-20: NULL-signature フォールバックが任意の class を + // metadata target 扱いしないこと。以前は legacy-migration DB で + // `signature IS NULL` のすべての C# class を許容しており、attribute 名と + // 同名の非 attribute class (`HelperClient`) が偽の曖昧さを発生させ得た。 + // 新しいフォールバックは C# の命名規約 `name LIKE '%Attribute'` を要求する + // ため、NULL-sig かつ非 *Attribute 名の class は候補から外れ、本物の + // `[MyAudit]` → `MyAuditAttribute` edge は両行の signature が NULL でも + // 残る。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/HelperClient.cs", "csharp", + """ + namespace Unrelated; + + public class HelperClient : BaseService + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + // Simulate partial-migration: all C# class rows have NULL signature. + // partial-migration 再現: すべての C# class 行の signature を NULL 化。 + using (var cmd = _db.Connection.CreateCommand()) + { + cmd.CommandText = "UPDATE symbols SET signature = NULL WHERE kind = 'class'"; + cmd.ExecuteNonQuery(); + } + + var deps = _reader.GetFileDependencies( + limit: 50, + lang: "csharp"); + + Assert.Contains(deps, d => + d.SourcePath == "src/Svc.cs" && + d.TargetPath == "src/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencies_JavaScript_SameNameInterface_DoesNotBlockFunctionDecoratorEdge() + { + // issue #293 round-20: TypeScript `interface` is a compile-time type-only + // construct and cannot be a runtime decorator target, so a same-name + // `interface` must NOT count toward metadata-target ambiguity against a + // real `function` provider. The metadata-target predicate for JS/TS + // therefore restricts candidate kinds to `class` and `function` only. + // issue #293 round-20: TS の `interface` はコンパイル時型のため runtime + // decorator target になれない。同名 `interface` が本物の `function` + // provider への decorator edge を潰さないよう、JS/TS の metadata-target + // 候補 kind は `class` と `function` に限定する。 + InsertIndexedFile("src/decorators.ts", "typescript", + """ + export function sealed(target: any): void { + Object.freeze(target); + } + """); + InsertIndexedFile("src/types.ts", "typescript", + """ + export interface sealed { + readonly frozen: boolean; + } + """); + InsertIndexedFile("src/model.ts", "typescript", + """ + import { sealed } from './decorators'; + + @sealed + class Foo { + } + """); + + var deps = _reader.GetFileDependencies( + limit: 50, + lang: "typescript"); + + Assert.Contains(deps, d => + d.SourcePath == "src/model.ts" && + d.TargetPath == "src/decorators.ts"); + } + + [Fact] + public void GetFileDependencies_CSharp_SameNameInterface_DoesNotBlockMetadataEdge() + { + // issue #293 round-18: ambiguity should only count truly attribute-eligible + // duplicates. In C#, only `class` can inherit from `System.Attribute` — + // a same-named `interface` or `struct` cannot be an attribute target, so it + // must not suppress the metadata deps edge to the legitimate attribute class. + // issue #293 round-18: ambiguity 判定は attribute 適格な重複だけを数えるべき。 + // C# では `class` のみが `System.Attribute` を継承できるため、同名の + // `interface` や `struct` が存在しても metadata deps edge を抑止してはならない。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/IMyAudit.cs", "csharp", + """ + public interface MyAuditAttribute + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + + var deps = _reader.GetFileDependencies( + limit: 50, + lang: "csharp"); + + Assert.Contains(deps, d => + d.SourcePath == "src/Svc.cs" && + d.TargetPath == "src/MyAuditAttribute.cs"); + } + + [Fact] + public void GetFileDependencyHints_MetadataBypassAmbiguityGuard_RespectsExcludeTests() + { + // issue #293 round-11 follow-up: ambiguity guard must honor + // `--exclude-tests`. A same-named class only present in tests should not + // suppress the bypass when the caller has already excluded tests from the + // impact scope. + // issue #293 round-11 追加: ambiguity guard は `--exclude-tests` を尊重すべき。 + // test 配下にしか存在しない同名定義が、test を除外した impact クエリの + // bypass を潰してはならない。 + InsertIndexedFile("src/MyAuditAttribute.cs", "csharp", + """ + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + InsertIndexedFile("src/Svc.cs", "csharp", + """ + [MyAudit] + public class Svc + { + } + """); + // Test-only same-named definition — must be filtered out when the caller + // passes excludeTests=true so the bypass stays active in the source scope. + // test 配下にしかない同名定義 — excludeTests=true のときはスコープ外になり、 + // source 側の bypass を維持すべき。 + InsertIndexedFile("tests/CodeIndex.Tests/MyAuditAttributeTests.cs", "csharp", + """ + public sealed class MyAuditAttribute : System.Attribute + { + } + """); + + var result = _reader.AnalyzeImpact( + "MyAuditAttribute", + maxDepth: 3, + limit: 20, + lang: "csharp", + excludeTests: true); + + Assert.Contains(result.FileImpacts, f => f.SourcePath == "src/Svc.cs" && f.TargetPath == "src/MyAuditAttribute.cs"); + } + [Fact] public void GetGroupedSymbolHotspots_CollapsesDuplicateNamesWithoutBareJoinInflation() { @@ -2951,6 +4294,46 @@ private void OnChanged(object? sender, EventArgs e) { } Assert.Equal("subscribe", bundledCallee.ReferenceKind); } + [Fact] + public void GetTransitiveCallers_FollowsSubscribeEdges() + { + // Regression: impact BFS must share the call-graph contract with callers/callees, + // so event subscriptions (`Changed += OnChanged`) also participate in the transitive + // caller chain rather than being stripped like metadata edges. + // リグレッション: impact BFS も callers/callees と同じ call-graph 契約を共有し、 + // イベント購読 (`Changed += OnChanged`) が transitive caller chain に含まれること。 + InsertIndexedFile("src/impact_subscribe_publisher.cs", "csharp", + """ + using System; + + public class SubPublisher + { + public event EventHandler? Changed; + } + """); + InsertIndexedFile("src/impact_subscribe_subscriber.cs", "csharp", + """ + using System; + + public class SubSubscriber + { + public void Hook(SubPublisher publisher) + { + publisher.Changed += OnChanged; + } + + private void OnChanged(object? sender, EventArgs e) { } + } + """); + + var (impact, truncated) = _reader.GetTransitiveCallers( + "Changed", maxDepth: 2, limit: 10, lang: "csharp", pathPatterns: ["impact_subscribe_"]); + + Assert.False(truncated); + var caller = Assert.Single(impact); + Assert.Equal("Hook", caller.CallerName); + } + [Fact] public void GetTransitiveCallers_ReturnsAllDirectCallersAcrossPages() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 67bf09ca84..6319f048dd 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -329,6 +329,30 @@ public void ToolsList_ExactAliasParametersAreExposed() Assert.NotNull(symbolsTool["inputSchema"]!["properties"]!["exact"]); } + [Fact] + public void ToolsList_CallersCalleesKindDescription_ExcludesMetadataKinds() + { + // Keep the `kind` schema description honest: the callers/callees handlers reject + // metadata kinds (`attribute`, `annotation`) as a usage error, so the schema must + // not advertise them as valid filter values. + // callers/callees の handler は metadata kinds (`attribute` / `annotation`) を拒否するため、 + // schema の `kind` description も有効値として列挙しないこと。 + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var response = _server.HandleMessage(request)!; + + var tools = response["result"]!["tools"]!.AsArray(); + foreach (var name in new[] { "callers", "callees" }) + { + var tool = tools.First(t => t!["name"]!.GetValue() == name)!; + var kindDescription = tool["inputSchema"]!["properties"]!["kind"]!["description"]!.GetValue(); + + Assert.Contains("call-graph", kindDescription); + Assert.Contains("call, instantiate, subscribe", kindDescription); + Assert.Contains("rejected", kindDescription); + Assert.Contains("references", kindDescription); + } + } + [Fact] public void ToolsList_ImpactAnalysisDescribesHeuristicFallback() { @@ -1124,6 +1148,36 @@ public void ToolsCall_Callees_UnsupportedLanguage_ReturnsGraphSupportHint() Assert.Contains("not indexed", response["result"]!["structuredContent"]!["graphSupportReason"]!.GetValue()); } + [Theory] + [InlineData("callers", "attribute")] + [InlineData("callers", "annotation")] + [InlineData("callees", "attribute")] + [InlineData("callees", "annotation")] + public void ToolsCall_CallersOrCallees_MetadataKindReturnsToolError(string tool, string kind) + { + // issue #293 follow-up: the MCP `callers` / `callees` tools must reject `kind: + // attribute` / `kind: annotation` because metadata rows are attributed to the + // enclosing body-range symbol (so `callers Obsolete kind=attribute` reports the + // enclosing class instead of the annotated method, and file-level targets drop + // entirely). AI clients should be redirected to the `references` tool for metadata + // enumeration. + // issue #293 補足: MCP の `callers` / `callees` ツールは `kind: attribute` / + // `kind: annotation` を必ず弾くこと。metadata 行は body-range の外側シンボルに帰属する + // ため、`callers Obsolete kind=attribute` は注釈対象のメソッドではなく外側クラスを返し、 + // file-level target は完全に脱落する。AI クライアントは metadata 列挙のために + // `references` ツールに誘導する。 + var requestJson = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"" + tool + "\"," + + "\"arguments\":{\"query\":\"SomeSymbol\",\"kind\":\"" + kind + "\"}}}"; + var request = JsonNode.Parse(requestJson)!; + var response = _server.HandleMessage(request)!; + + Assert.True(response["result"]!["isError"]!.GetValue()); + var text = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains($"'kind: {kind}' is not supported on '{tool}'", text); + Assert.Contains("'references' tool", text); + } + [Fact] public void ToolsCall_ImpactAnalysis_ClassSymbolReturnsHeuristicFileDependencyHints() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 2aa8f198f7..a167eeb0be 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -2202,6 +2202,49 @@ public string Render() } } + [Theory] + [InlineData("callers", "attribute")] + [InlineData("callers", "annotation")] + [InlineData("callees", "attribute")] + [InlineData("callees", "annotation")] + public void RunCallersCallees_RejectMetadataKind_WithUsageError(string command, string kind) + { + // issue #293 follow-up: `callers` / `callees` must reject `--kind attribute` and + // `--kind annotation` at the CLI boundary. Metadata references are attributed to the + // enclosing body-range symbol rather than the annotated target, so `callers Obsolete + // --kind attribute` would return `[Obsolete] void M()` under the enclosing class + // instead of `M`, and file-level targets like `[assembly: Foo]` drop entirely because + // `container_name` is NULL. The correct path for metadata enumeration is + // `references --kind attribute|annotation`. + // issue #293 補足: `callers` / `callees` は CLI 境界で `--kind attribute` / + // `--kind annotation` を必ず弾かなければならない。metadata 参照は注釈対象ではなく + // body-range の外側シンボルに帰属するため、`callers Obsolete --kind attribute` では + // `[Obsolete] void M()` が `M` ではなく外側クラスに寄り、`[assembly: Foo]` のような + // file-level target は `container_name = NULL` で完全に脱落する。metadata 列挙の + // 正しい経路は `references --kind attribute|annotation`。 + var projectRoot = TestProjectHelper.CreateTempProject($"cdidx_{command}_reject_kind_{kind}"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var args = new[] { "Symbol", "--db", dbPath, "--kind", kind }; + + var (exitCode, _, stderr) = command switch + { + "callers" => CaptureConsole(() => QueryCommandRunner.RunCallers(args, _jsonOptions)), + "callees" => CaptureConsole(() => QueryCommandRunner.RunCallees(args, _jsonOptions)), + _ => throw new InvalidOperationException($"Unexpected command: {command}") + }; + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains($"'--kind {kind}' is not supported on '{command}'", stderr); + Assert.Contains($"references --kind {kind}", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunCallers_JsonZeroResults_WithMissingGraphTable_ReturnsDegradedPayload() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index 878634b201..704f644c34 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -789,8 +789,13 @@ public void Use() } [Fact] - public void Extract_CsharpReturnTargetAttribute_CallIsNotDropped() + public void Extract_CsharpReturnTargetAttribute_ReferenceIsRecordedAsAttribute() { + // issue #293: C# attributes (including targeted `[return: ...]` form) are recorded as + // `attribute` kind — the reference must not be dropped, but also must not pollute the + // call-graph with a phantom `call` row. + // issue #293: C# 属性(`[return: ...]` の target 付きも含む)は `attribute` として + // 記録される。参照自体は失われないが、call-graph を `call` 行で汚染してはならない。 const string content = """ using System.Runtime.InteropServices; @@ -807,14 +812,15 @@ public class Foo var symbols = SymbolExtractor.Extract(1, "csharp", content); var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); - var marshalAsCalls = references - .Where(reference => reference.SymbolName == "MarshalAs" && reference.ReferenceKind == "call") + var marshalAsRefs = references + .Where(reference => reference.SymbolName == "MarshalAs") .OrderBy(reference => reference.Line) .ToList(); - Assert.Equal(2, marshalAsCalls.Count); - Assert.Equal([5, 8], marshalAsCalls.Select(reference => reference.Line).ToArray()); - Assert.All(marshalAsCalls, reference => Assert.Equal("Foo", reference.ContainerName)); + Assert.Equal(2, marshalAsRefs.Count); + Assert.Equal([5, 8], marshalAsRefs.Select(reference => reference.Line).ToArray()); + Assert.All(marshalAsRefs, reference => Assert.Equal("attribute", reference.ReferenceKind)); + Assert.All(marshalAsRefs, reference => Assert.Equal("Foo", reference.ContainerName)); } [Fact] @@ -4844,4 +4850,1213 @@ public void Extract_SqlCallBacktickIdentifierContainingHash_IsCaptured() Assert.Contains(references, r => r.SymbolName == "proc#1" && r.ReferenceKind == "call" && r.Line == 1); Assert.Contains(references, r => r.SymbolName == "proc#sqlserver" && r.ReferenceKind == "call" && r.Line == 2); } + [Fact] + public void Extract_CsharpAttribute_ClassifiedAsAttribute() + { + // issue #293: `[Obsolete("msg")]` must produce an `attribute` reference, not a phantom `call`. + // issue #293: `[Obsolete("msg")]` は `call` ではなく `attribute` として記録されること。 + const string content = """ + using System; + [Obsolete("old")] + public class Old + { + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var obsolete = Assert.Single(references.Where(r => r.SymbolName == "Obsolete")); + Assert.Equal("attribute", obsolete.ReferenceKind); + } + + [Fact] + public void Extract_CsharpTargetedAttribute_ClassifiedAsAttribute() + { + // issue #293: `[return: NotNull("x")]` targeted attribute is classified as `attribute`. + // issue #293: `[return: NotNull("x")]` のターゲット付き属性も `attribute` になること。 + const string content = """ + public class C + { + [return: NotNull("x")] + public string M() => string.Empty; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var notNull = Assert.Single(references.Where(r => r.SymbolName == "NotNull")); + Assert.Equal("attribute", notNull.ReferenceKind); + } + + [Fact] + public void Extract_CsharpMultipleAttributes_ClassifiedAsAttribute() + { + // issue #293: `[Foo("a"), Bar("b")]` — both entries in a comma-separated attribute list + // must be classified as `attribute`. + // issue #293: `[Foo("a"), Bar("b")]` のカンマ区切り属性リストは全て `attribute` になること。 + const string content = """ + [Foo("a"), Bar("b")] + public class C { } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var foo = Assert.Single(references.Where(r => r.SymbolName == "Foo")); + var bar = Assert.Single(references.Where(r => r.SymbolName == "Bar")); + Assert.Equal("attribute", foo.ReferenceKind); + Assert.Equal("attribute", bar.ReferenceKind); + } + + [Fact] + public void Extract_CsharpAttributeWithNewArgument_InstantiateStaysInstantiate() + { + // Inside attribute arguments, `new Foo()` still counts as `instantiate` — only the + // attribute identifier itself is reclassified. + // 属性引数内の `new Foo()` は従来通り `instantiate`。属性名本体のみが再分類される。 + const string content = """ + [AttributeUsage(AttributeTargets.Class)] + public class C { } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var au = Assert.Single(references.Where(r => r.SymbolName == "AttributeUsage")); + Assert.Equal("attribute", au.ReferenceKind); + } + + [Fact] + public void Extract_CsharpMethodBodyCall_StaysCall() + { + // Regression guard: ordinary method calls inside method bodies must still produce `call`, + // not be mistaken for attribute references due to unrelated `[` tokens on nearby lines. + // 回帰防止: メソッド本体内の通常呼び出しは、近くの `[` トークンの影響で `attribute` と + // 誤判定されず `call` のまま残ること。 + const string content = """ + public class C + { + public int Run() => Compute(42); + public int Compute(int x) => x; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var compute = Assert.Single(references.Where(r => r.SymbolName == "Compute")); + Assert.Equal("call", compute.ReferenceKind); + } + + [Fact] + public void Extract_JavaAnnotation_ClassifiedAsAnnotation() + { + // issue #293: `@Deprecated(since="1.0")` must produce an `annotation` reference, not a phantom `call`. + // issue #293: `@Deprecated(since="1.0")` は `call` ではなく `annotation` として記録されること。 + const string content = """ + public class AnnotatedClass { + @Deprecated(since="1.0") + public void doWork() { } + } + """; + + var symbols = SymbolExtractor.Extract(1, "java", content); + var references = ReferenceExtractor.Extract(1, "java", content, symbols); + + var deprecated = Assert.Single(references.Where(r => r.SymbolName == "Deprecated")); + Assert.Equal("annotation", deprecated.ReferenceKind); + } + + [Fact] + public void Extract_JavaQualifiedAnnotation_ClassifiedAsAnnotation() + { + // issue #293: `@org.junit.Test(timeout=1000)` — dotted qualifier chain still resolves to `@`. + // issue #293: `@org.junit.Test(timeout=1000)` のような修飾付き注釈も `annotation` になること。 + const string content = """ + public class T { + @org.junit.Test(timeout=1000) + public void testIt() { } + } + """; + + var symbols = SymbolExtractor.Extract(1, "java", content); + var references = ReferenceExtractor.Extract(1, "java", content, symbols); + + var testAnno = Assert.Single(references.Where(r => r.SymbolName == "Test")); + Assert.Equal("annotation", testAnno.ReferenceKind); + } + + [Fact] + public void Extract_KotlinAnnotation_ClassifiedAsAnnotation() + { + // issue #293: Kotlin `@Deprecated("msg")` also emits `annotation`. + // issue #293: Kotlin の `@Deprecated("msg")` も `annotation` になること。 + const string content = """ + class K { + @Deprecated("msg") + fun old() {} + } + """; + + var symbols = SymbolExtractor.Extract(1, "kotlin", content); + var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); + + var deprecated = Assert.Single(references.Where(r => r.SymbolName == "Deprecated")); + Assert.Equal("annotation", deprecated.ReferenceKind); + } + + [Fact] + public void Extract_SwiftAttributeWithArgs_ClassifiedAsAnnotation() + { + // issue #293 follow-up: Swift `@available(...)` / `@objc` / `@MainActor` are + // compile-time metadata, not runtime calls. Before the fix they were recorded + // as `call` references (polluting `callers`/`callees`/`hotspots`/`impact`) and + // `@objc` / `@MainActor` no-arg attributes dropped entirely from the index. + // After the fix they must all classify as `annotation`. + // issue #293 補足: Swift の `@available(...)` / `@objc` / `@MainActor` は compile-time + // metadata であり runtime の call ではない。修正前は `call` として記録され + // (`callers`/`callees`/`hotspots`/`impact` が汚染)、`@objc` / `@MainActor` の no-arg + // 版はインデックスから完全に脱落していた。修正後はすべて `annotation` として分類される。 + const string content = """ + import Foundation + + @available(iOS 13.0, *) + class NetworkClient { + @objc func fetch() {} + + @MainActor + func process() {} + } + """; + + var symbols = SymbolExtractor.Extract(1, "swift", content); + var references = ReferenceExtractor.Extract(1, "swift", content, symbols); + + var available = Assert.Single(references.Where(r => r.SymbolName == "available")); + Assert.Equal("annotation", available.ReferenceKind); + + var objc = Assert.Single(references.Where(r => r.SymbolName == "objc")); + Assert.Equal("annotation", objc.ReferenceKind); + + var mainActor = Assert.Single(references.Where(r => r.SymbolName == "MainActor")); + Assert.Equal("annotation", mainActor.ReferenceKind); + } + + [Fact] + public void Extract_GradleAnnotation_ClassifiedAsAnnotation() + { + // issue #293 follow-up: Gradle/Groovy `@CompileStatic` / `@TaskAction` and similar + // transform/task annotations are compile-time metadata. Before the fix they were + // recorded as `call` references (or dropped for the no-arg form), which made + // `callers TaskAction` / `callees` pick up fake graph edges in build scripts. + // After the fix they must all classify as `annotation`. + // issue #293 補足: Gradle/Groovy の `@CompileStatic` / `@TaskAction` なども compile-time + // metadata。修正前は `call` として記録されるか no-arg 版が脱落し、ビルドスクリプトで + // `callers TaskAction` / `callees` に偽のグラフエッジが混入していた。修正後はすべて + // `annotation` として分類される。 + const string content = """ + import groovy.transform.CompileStatic + + @CompileStatic + class BuildConfig { + @TaskAction + void run() { + println "built" + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "gradle", content); + var references = ReferenceExtractor.Extract(1, "gradle", content, symbols); + + var compileStatic = Assert.Single(references.Where(r => r.SymbolName == "CompileStatic")); + Assert.Equal("annotation", compileStatic.ReferenceKind); + + var taskAction = Assert.Single(references.Where(r => r.SymbolName == "TaskAction")); + Assert.Equal("annotation", taskAction.ReferenceKind); + } + + [Fact] + public void Extract_JavaMethodBodyCall_StaysCall() + { + // Regression guard: ordinary Java method call remains `call`, not `annotation`. + // 回帰防止: Java のメソッド本体内の通常呼び出しは `annotation` に誤判定されず `call` のまま。 + const string content = """ + public class J { + public int add(int a, int b) { return compute(a, b); } + public int compute(int a, int b) { return a + b; } + } + """; + + var symbols = SymbolExtractor.Extract(1, "java", content); + var references = ReferenceExtractor.Extract(1, "java", content, symbols); + + var compute = Assert.Single(references.Where(r => r.SymbolName == "compute")); + Assert.Equal("call", compute.ReferenceKind); + } + + [Fact] + public void Extract_CsharpCollectionExpression_StaysCall() + { + // issue #293 regression: C# 12 collection expressions `var xs = [Make(), Make()]` + // share the `[...]` syntax with attributes but must NOT be classified as `attribute`. + // issue #293 回帰防止: C# 12 collection expression `var xs = [Make(), Make()]` は + // 属性と同じ `[...]` 構文を共有するが `attribute` に誤分類してはならない。 + const string content = """ + public class C + { + public int Make() => 42; + public void Run() + { + var xs = [Make(), Make()]; + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var makeRefs = references.Where(r => r.SymbolName == "Make").ToList(); + Assert.Equal(2, makeRefs.Count); + Assert.All(makeRefs, r => Assert.Equal("call", r.ReferenceKind)); + Assert.All(makeRefs, r => Assert.Equal("Run", r.ContainerName)); + } + + [Fact] + public void Extract_CsharpCollectionExpressionInArgument_StaysCall() + { + // Collection expressions appearing as arguments, nested in other expressions, or + // after `return` must still classify inner calls as `call`, not `attribute`. + // 引数やネストされた式、`return` 後の collection expression 内の呼び出しは `call` のまま。 + const string content = """ + public class C + { + public int Make() => 42; + public int[] Wrap() => [Make(), Make()]; + public void Consume(int[] xs) { } + public void Run() + { + Consume([Make(), Make()]); + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var makeRefs = references.Where(r => r.SymbolName == "Make").ToList(); + Assert.Equal(4, makeRefs.Count); + Assert.All(makeRefs, r => Assert.Equal("call", r.ReferenceKind)); + } + + [Fact] + public void Extract_CsharpIndexerAccess_StaysCall() + { + // `arr[Compute()]` — `[` is preceded by an identifier, so it is an indexer, not an + // attribute, and the inner call must stay `call`. + // `arr[Compute()]` は indexer で、`[` の直前が識別子のため attribute 扱いにはしない。 + const string content = """ + public class C + { + public int Compute() => 0; + public int Read(int[] arr) => arr[Compute()]; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var compute = Assert.Single(references.Where(r => r.SymbolName == "Compute")); + Assert.Equal("call", compute.ReferenceKind); + } + + [Fact] + public void Extract_KotlinFieldTargetAnnotation_ClassifiedAsAnnotation() + { + // issue #293 follow-up: Kotlin use-site target `@field:Deprecated("msg")` must be + // classified as `annotation`, not `call`. + // issue #293 補足: Kotlin の use-site target `@field:Deprecated("msg")` も `annotation`。 + const string content = """ + class Example { + @field:Deprecated("msg") + val value: Int = 0 + } + """; + + var symbols = SymbolExtractor.Extract(1, "kotlin", content); + var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); + + var deprecated = Assert.Single(references.Where(r => r.SymbolName == "Deprecated")); + Assert.Equal("annotation", deprecated.ReferenceKind); + } + + [Fact] + public void Extract_KotlinGetTargetAnnotation_ClassifiedAsAnnotation() + { + // Kotlin `@get:JsonName("x")` property getter target annotation. + // Kotlin の `@get:JsonName("x")` プロパティ getter 向け注釈も `annotation`。 + const string content = """ + class K { + @get:JsonName("x") + val x: Int = 0 + } + """; + + var symbols = SymbolExtractor.Extract(1, "kotlin", content); + var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); + + var jsonName = Assert.Single(references.Where(r => r.SymbolName == "JsonName")); + Assert.Equal("annotation", jsonName.ReferenceKind); + } + + [Fact] + public void Extract_KotlinFileTargetAnnotation_ClassifiedAsAnnotation() + { + // Kotlin `@file:JvmName("Foo")` file-level target annotation. + // Kotlin の `@file:JvmName("Foo")` ファイル単位注釈も `annotation`。 + const string content = """ + @file:JvmName("Foo") + + package example + """; + + var symbols = SymbolExtractor.Extract(1, "kotlin", content); + var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); + + var jvmName = Assert.Single(references.Where(r => r.SymbolName == "JvmName")); + Assert.Equal("annotation", jvmName.ReferenceKind); + } + + [Fact] + public void Extract_CsharpChainedIndexerCalls_StayCall() + { + // issue #293 follow-up: `arr[Compute()][Compute()]` — the second `[` is preceded by + // an indexer-closing `]`, not an attribute-section `]`. Walking back to the matching + // `[` must find an expression-position bracket so both inner calls remain `call`. + // issue #293 補足: `arr[Compute()][Compute()]` の 2 個目の `[` は indexer の `]` に + // 続くだけで attribute section の終端ではないため、対応する `[` まで戻って宣言位置で + // ないことを確認し、両方の呼び出しを `call` のまま残す。 + const string content = """ + public class C + { + public int Compute() => 0; + public int Read(int[][] arr) => arr[Compute()][Compute()]; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var computeRefs = references.Where(r => r.SymbolName == "Compute").ToList(); + Assert.Equal(2, computeRefs.Count); + Assert.All(computeRefs, r => Assert.Equal("call", r.ReferenceKind)); + } + + [Fact] + public void Extract_CsharpMatrixIndexerCalls_StayCall() + { + // Two consecutive indexer accesses on a matrix — `matrix[Row()][Col()]` — must keep + // both inner calls as `call`. + // 連続 indexer `matrix[Row()][Col()]` でも、両方の呼び出しが `call` のまま残ること。 + const string content = """ + public class M + { + public int Row() => 0; + public int Col() => 0; + public int Read(int[,] matrix) => matrix[Row(), Col()]; + public int Read2(int[][] grid) => grid[Row()][Col()]; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var row = references.Where(r => r.SymbolName == "Row").ToList(); + var col = references.Where(r => r.SymbolName == "Col").ToList(); + Assert.Equal(2, row.Count); + Assert.Equal(2, col.Count); + Assert.All(row, r => Assert.Equal("call", r.ReferenceKind)); + Assert.All(col, r => Assert.Equal("call", r.ReferenceKind)); + } + + [Fact] + public void Extract_CsharpChainedAttributeLists_StayAttribute() + { + // `[A(...)][B(...)]` on a declaration — the chained attribute-list form must still + // classify both entries as `attribute` after the indexer-safety walk-back. + // `[A(...)][B(...)]` の連続 attribute list は、indexer との区別が入ったあとも両方 `attribute`。 + const string content = """ + [A("x")][B("y")] + public class C { } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var a = Assert.Single(references.Where(r => r.SymbolName == "A")); + var b = Assert.Single(references.Where(r => r.SymbolName == "B")); + Assert.Equal("attribute", a.ReferenceKind); + Assert.Equal("attribute", b.ReferenceKind); + } + + [Fact] + public void Extract_CsharpParameterAttributes_ClassifiedAsAttribute() + { + // Parameter attributes are introduced by `(` or `,` rather than a scope boundary, so + // the classifier must use forward lookahead from `[` to disambiguate against C# 12 + // collection expressions in argument position like `Consume([Make()])`. + // パラメータ属性は `(` や `,` に続くため、collection expression と区別するには `[` から + // 対応する `]` まで前方を走査して、直後が識別子かを確認する必要がある。 + const string content = """ + public class C + { + public void M([Attr("x")] int a, [Other("y")] int b) { } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var attr = Assert.Single(references.Where(r => r.SymbolName == "Attr")); + var other = Assert.Single(references.Where(r => r.SymbolName == "Other")); + Assert.Equal("attribute", attr.ReferenceKind); + Assert.Equal("attribute", other.ReferenceKind); + } + + [Fact] + public void Extract_CsharpMultiLineAttribute_ClassifiedAsAttribute() + { + // A multi-line attribute list `[\n Foo("x")\n ]` must still classify `Foo` as + // attribute even though the opening `[` is not on the same line as the identifier. + // `[` と `Foo("x")` が別行にある場合でも `Foo` を属性として判定すること。 + const string content = """ + [ + Foo("x") + ] + public class C { } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var foo = Assert.Single(references.Where(r => r.SymbolName == "Foo")); + Assert.Equal("attribute", foo.ReferenceKind); + } + + [Fact] + public void Extract_CsharpMultiLineParameterAttribute_ClassifiedAsAttribute() + { + // Parameter attribute split across lines — `(` ends one line, `[Attr]` sits on the + // next, and the declaration continues after. Cross-line lookahead must still find + // the identifier after the matching `]`. + // 改行を挟んだパラメータ属性でも、跨行 lookahead で `]` の直後に続く識別子まで到達し、 + // 属性として判定できること。 + const string content = """ + public class C + { + public void M( + [Attr("x")] + int a) + { + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var attr = Assert.Single(references.Where(r => r.SymbolName == "Attr")); + Assert.Equal("attribute", attr.ReferenceKind); + } + + [Fact] + public void Extract_CsharpTargetedAttribute_StaysAttribute() + { + // Regression: `[return: NotNullWhen(true)]` is recognised as an attribute section by + // the pre-pass (bracket position, not `target:` heuristics). Keep the case covered. + // リグレッション: `[return: NotNullWhen(true)]` も属性セクションとして判定されること。 + const string content = """ + public class C + { + [return: NotNullWhen(true)] + public bool Try() => true; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var notNullWhen = Assert.Single(references.Where(r => r.SymbolName == "NotNullWhen")); + Assert.Equal("attribute", notNullWhen.ReferenceKind); + } + + [Fact] + public void Extract_CsharpCollectionExpressionInArgument_StaysCallAfterParen() + { + // Defense-in-depth: `Consume([Make()])` has `[` immediately after `(`, matching the + // parameter-attribute entry point, but forward lookahead sees `)` after the matching + // `]` and correctly keeps `Make` as `call`. + // `Consume([Make()])` のように `(` 直後に `[` が続くケースでも、`]` の直後が `)` であれば + // collection expression として `call` のままであること。 + const string content = """ + public class C + { + public void M() + { + Consume([Make(), Make()]); + } + private static int Make() => 0; + private void Consume(int[] xs) { } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var makeRefs = references.Where(r => r.SymbolName == "Make").ToList(); + Assert.Equal(2, makeRefs.Count); + Assert.All(makeRefs, r => Assert.Equal("call", r.ReferenceKind)); + } + + [Fact] + public void Extract_CsharpCollectionExpressionPatternMatch_StaysCall() + { + // Regression: `[Make()] is int[] xs` is a pattern expression, not an attribute. The + // next token after `]` is the contextual keyword `is`, so `Make` must stay `call`. + // リグレッション: `[Make()] is int[] xs` はパターン式なので、`]` の次の `is` を属性の続きと誤認せず `Make` は `call`。 + const string content = """ + public class C + { + public bool M() + { + return Consume([Make()] is int[] xs && xs.Length > 0); + } + private static int Make() => 0; + private bool Consume(bool b) => b; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var make = Assert.Single(references.Where(r => r.SymbolName == "Make")); + Assert.Equal("call", make.ReferenceKind); + } + + [Fact] + public void Extract_CsharpCollectionExpressionAsCast_StaysCall() + { + // Regression: `[Make()] as int[]` is an `as` cast, not an attribute. The classifier + // must treat `as` as expression continuation and keep `Make` as `call`. + // リグレッション: `[Make()] as int[]` は `as` キャストなので `Make` は `call` のまま。 + const string content = """ + public class C + { + public void M() + { + var arr = ([Make()] as int[]); + } + private static int Make() => 0; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var make = Assert.Single(references.Where(r => r.SymbolName == "Make")); + Assert.Equal("call", make.ReferenceKind); + } + + [Fact] + public void Extract_CsharpCollectionExpressionSwitchExpression_StaysCall() + { + // Regression: `[Make()] switch { ... }` is a switch expression over a collection, + // not an attribute. The classifier must treat `switch` as expression continuation. + // リグレッション: `[Make()] switch { ... }` は collection に対する switch 式のため `Make` は `call`。 + const string content = """ + public class C + { + public bool M() => Consume([Make()] switch { _ => true }); + private static int Make() => 0; + private bool Consume(bool b) => b; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var make = Assert.Single(references.Where(r => r.SymbolName == "Make")); + Assert.Equal("call", make.ReferenceKind); + } + + [Fact] + public void Extract_CsharpTupleTypedParameterAttribute_ClassifiedAsAttribute() + { + // Regression: `void M([Attr("x")] (int, int) value)` — the token after `]` is `(` + // (tuple type syntax), which must still be treated as a declaration start so the + // preceding `[...]` is classified as an attribute. + // リグレッション: `void M([Attr("x")] (int, int) value)` のように `]` の直後が tuple 型の `(` でも属性扱い。 + const string content = """ + public class C + { + public void M([Attr("x")] (int a, int b) value) { } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var attr = Assert.Single(references.Where(r => r.SymbolName == "Attr")); + Assert.Equal("attribute", attr.ReferenceKind); + } + + [Fact] + public void Extract_CsharpTypeParameterAttribute_ClassifiedAsAttribute() + { + // Regression: `class C<[Attr("x")] T>` — the `[` is preceded by `<`, which is a valid + // attribute position for type parameters. The classifier must accept `<` alongside + // `(` and `,` as parameter-list entry points. + // リグレッション: `class C<[Attr("x")] T>` のように `<` の直後にある型パラメータ属性も検出できること。 + const string content = """ + public class C<[Attr("x")] T> + { + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var attr = Assert.Single(references.Where(r => r.SymbolName == "Attr")); + Assert.Equal("attribute", attr.ReferenceKind); + } + + [Fact] + public void Extract_CsharpLambdaAttribute_ClassifiedAsAttribute() + { + // Regression: `var f = [Attr("x")] () => 0;` — the `[` is preceded by `=`, and the token + // after `]` is `(` (lambda parameter list). The classifier must accept `=` as a valid + // attribute-entry context alongside `(`, `,`, `<`. + // リグレッション: `var f = [Attr("x")] () => 0;` のように `=` の直後にあるラムダ属性も検出できること。 + const string content = """ + public class C + { + public void M() + { + var f = [Attr("x")] () => 0; + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var attr = Assert.Single(references.Where(r => r.SymbolName == "Attr")); + Assert.Equal("attribute", attr.ReferenceKind); + } + + [Fact] + public void Extract_CsharpNoArgAttribute_ClassifiedAsAttribute() + { + // Regression (issue #293): `[Serializable]`, `[Obsolete]`, `[System.Obsolete]`, + // `[assembly: CLSCompliant]`, `[Required, Key]` — bare no-arg attributes were not + // indexed at all because CallRegex requires `(`. A dedicated no-arg entry path + // must emit them with kind `attribute`. + // リグレッション (issue #293): `[Serializable]` などの引数なし属性も `attribute` として + // インデックスされること。CallRegex は `(` を要求するため専用の取り込み経路が必要。 + const string content = """ + [assembly: CLSCompliant] + [Serializable] + [Obsolete] + [System.Obsolete] + [Required, Key] + public class C + { + } + """; + + var references = ReferenceExtractor.Extract(1, "csharp", content, []); + + Assert.Single(references.Where(r => r.SymbolName == "CLSCompliant" && r.ReferenceKind == "attribute")); + Assert.Single(references.Where(r => r.SymbolName == "Serializable" && r.ReferenceKind == "attribute")); + // `[System.Obsolete]` — the qualifier chain is part of the attribute, and the emitted + // reference should carry the final segment (`Obsolete`). There are two `Obsolete` rows + // (the plain `[Obsolete]` above and the qualified `[System.Obsolete]`), both attribute. + Assert.Equal(2, references.Count(r => r.SymbolName == "Obsolete" && r.ReferenceKind == "attribute")); + Assert.Single(references.Where(r => r.SymbolName == "Required" && r.ReferenceKind == "attribute")); + Assert.Single(references.Where(r => r.SymbolName == "Key" && r.ReferenceKind == "attribute")); + Assert.DoesNotContain(references, r => r.ReferenceKind == "call"); + } + + [Fact] + public void Extract_CsharpIndexerAccess_NotClassifiedAsAttribute() + { + // Regression (issue #293): `arr[i]` looks like a bare `[name]` token, but it is an + // indexer expression, not an attribute. The no-arg attribute path must defer to the + // attribute-range pre-pass so indexer access is not misclassified as `attribute`. + // リグレッション (issue #293): `arr[i]` のような indexer アクセスは `[name]` 形だが + // 属性ではない。属性レンジ pre-pass を経由することで attribute への誤分類を防ぐ。 + const string content = """ + public class C + { + public int M(int[] arr, int i) => arr[i]; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.DoesNotContain(references, r => r.SymbolName == "i" && r.ReferenceKind == "attribute"); + } + + [Fact] + public void Extract_JavaNoArgAnnotation_ClassifiedAsAnnotation() + { + // Regression (issue #293): `@Deprecated`, `@Override`, `@org.junit.Test` — bare no-arg + // annotations were not indexed because CallRegex requires `(`. A dedicated no-arg + // regex must emit them with kind `annotation`. + // リグレッション (issue #293): `@Deprecated` などの引数なし Java annotation も + // `annotation` として認識されること。 + const string content = """ + public class C { + @Deprecated + @Override + @org.junit.Test + public void m() {} + } + """; + + var symbols = SymbolExtractor.Extract(1, "java", content); + var references = ReferenceExtractor.Extract(1, "java", content, symbols); + + Assert.Single(references.Where(r => r.SymbolName == "Deprecated" && r.ReferenceKind == "annotation")); + Assert.Single(references.Where(r => r.SymbolName == "Override" && r.ReferenceKind == "annotation")); + Assert.Single(references.Where(r => r.SymbolName == "Test" && r.ReferenceKind == "annotation")); + } + + [Fact] + public void Extract_KotlinNoArgTargetAnnotation_ClassifiedAsAnnotation() + { + // Regression (issue #293): `@field:Deprecated` — use-site target without parentheses. + // リグレッション (issue #293): `@field:Deprecated` のような use-site target 付き + // 引数なしアノテーションも `annotation` 判定になること。 + const string content = """ + class C { + @field:Deprecated + val x: Int = 0 + } + """; + + var references = ReferenceExtractor.Extract(1, "kotlin", content, []); + + var deprecated = Assert.Single(references.Where(r => r.SymbolName == "Deprecated")); + Assert.Equal("annotation", deprecated.ReferenceKind); + } + + [Fact] + public void Extract_KotlinReturnAtLabel_NotClassifiedAsAnnotation() + { + // Regression (issue #293): `return@foo` is a Kotlin label reference, not an annotation. + // The leading lookbehind `(? r.SymbolName == "foo" && r.ReferenceKind == "annotation"); + } + + [Fact] + public void Extract_CsharpGlobalQualifiedNoArgAttribute_ClassifiedAsAttribute() + { + // Regression (issue #293 follow-up): `[global::System.Obsolete]` — fully qualified + // attribute using the `global::` alias. The no-arg attribute regex must accept both + // `.` and `::` as qualifier separators so these references are not silently dropped. + // リグレッション (issue #293 補足): `[global::System.Obsolete]` のように `::` で修飾した + // 引数なし属性も `attribute` として取り込まれること。 + const string content = """ + [global::System.Obsolete] + public class C + { + } + """; + + var references = ReferenceExtractor.Extract(1, "csharp", content, []); + + var obsolete = Assert.Single(references.Where(r => r.SymbolName == "Obsolete")); + Assert.Equal("attribute", obsolete.ReferenceKind); + Assert.DoesNotContain(references, r => r.ReferenceKind == "call"); + } + + [Fact] + public void Extract_CsharpMultiLineNoArgAttribute_ClassifiedAsAttribute() + { + // Regression (issue #293 follow-up): multi-line no-arg attribute forms such as + // `[\n Serializable\n]`, `[\n global::System.Obsolete\n]`, and `[Serializable,\n Obsolete]` + // must still classify as `attribute`. The attribute range pre-pass already tracks the + // section across line breaks; the no-arg regex must not reject identifiers just because + // the opening `[` or `,` is on a previous line. + // リグレッション (issue #293 補足): `[\n Serializable\n]` のように `[` と識別子が別行に + // ある複数行形、`[\n global::System.Obsolete\n]` のような `::` 修飾複数行形、そして + // `[Serializable,\n Obsolete]` のような行を跨ぐカンマ区切りも `attribute` として取り込まれること。 + const string content = """ + [ + Serializable + ] + [ + global::System.Obsolete + ] + [Required, + Key] + public class C + { + } + """; + + // Use SymbolExtractor to mirror end-to-end indexing: if SymbolExtractor misclassifies + // a bare identifier inside a multi-line attribute section as a top-level symbol, the + // reference would be filtered out via the `definitionNames` guard and this test would + // catch that regression instead of silently passing with `[]` symbols. + // SymbolExtractor を通すことで end-to-end と同じ流れを再現する。複数行属性セクション内の + // 裸識別子を誤ってトップレベルのシンボルとして抽出してしまうと `definitionNames` ガードで + // 参照が脱落してしまうため、本テストがその退行も検出する。 + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var serializable = Assert.Single(references.Where(r => r.SymbolName == "Serializable")); + Assert.Equal("attribute", serializable.ReferenceKind); + var obsolete = Assert.Single(references.Where(r => r.SymbolName == "Obsolete")); + Assert.Equal("attribute", obsolete.ReferenceKind); + var required = Assert.Single(references.Where(r => r.SymbolName == "Required")); + Assert.Equal("attribute", required.ReferenceKind); + var key = Assert.Single(references.Where(r => r.SymbolName == "Key")); + Assert.Equal("attribute", key.ReferenceKind); + Assert.DoesNotContain(references, r => r.ReferenceKind == "call"); + } + + [Fact] + public void Extract_CsharpGenericNoArgAttribute_ClassifiedAsAttribute() + { + // Regression (issue #293 follow-up): generic no-arg C# attributes such as + // `[MyAudit]`, `[assembly: MyAttr]`, and multi-line `[\n MyAttr\n]` + // must still classify as `attribute`. The no-arg attribute regex must accept an + // optional generic argument list after the name so these references are indexed. + // リグレッション (issue #293 補足): `[MyAudit]` などのジェネリック引数なし属性、 + // `[assembly: MyAttr]` のような assembly targeted 形、そして複数行の + // `[\n MyAttr\n]` も `attribute` として取り込まれること。 + const string content = """ + [assembly: MyAttr] + [MyAudit] + [ + MyAttr + ] + public class C + { + } + """; + + var references = ReferenceExtractor.Extract(1, "csharp", content, []); + + var myAudit = Assert.Single(references.Where(r => r.SymbolName == "MyAudit")); + Assert.Equal("attribute", myAudit.ReferenceKind); + Assert.Equal(2, references.Count(r => r.SymbolName == "MyAttr" && r.ReferenceKind == "attribute")); + Assert.DoesNotContain(references, r => r.ReferenceKind == "call"); + } + + [Fact] + public void Extract_CsharpNestedGenericNoArgAttribute_ClassifiedAsAttribute() + { + // Regression (issue #293 round-16 follow-up): nested generic no-arg C# + // attributes such as `[MyAttr>]` and + // `[MyAttr>>]` must still classify as + // `attribute`. The previous `<[^>\n]+>` generic segment stopped at the + // first `>` and left the outer `>` dangling, so nested-generic + // attributes were silently dropped from the index. + // リグレッション (issue #293 round-16 補足): `[MyAttr>]` + // のような入れ子ジェネリック引数を持つ引数なし属性も `attribute` として + // 取り込まれること。`<...>` 内部で `>` を除外する以前の実装では最初の `>` + // で止まってしまい、nested generic 属性が黙って脱落していた。 + const string content = """ + [MyAttr>] + [MyOther>>] + [ + MyMulti>> + ] + public class C + { + } + """; + + var references = ReferenceExtractor.Extract(1, "csharp", content, []); + + var a = Assert.Single(references.Where(r => r.SymbolName == "MyAttr")); + Assert.Equal("attribute", a.ReferenceKind); + var b = Assert.Single(references.Where(r => r.SymbolName == "MyOther")); + Assert.Equal("attribute", b.ReferenceKind); + var c = Assert.Single(references.Where(r => r.SymbolName == "MyMulti")); + Assert.Equal("attribute", c.ReferenceKind); + Assert.DoesNotContain(references, r => r.ReferenceKind == "call"); + } + + [Fact] + public void Extract_CsharpNoArgParameterAttribute_ClassifiedAsAttribute() + { + // Regression (issue #293 follow-up): no-arg parameter attributes such as + // `void M([FromServices] IService s)` must still classify as `attribute`. + // A previous iteration of the top-level-zone gate tracked paren depth globally + // so the attribute section — which opens at global paren depth 1 inside the + // method parameter list — never entered top-level. The fix is to track paren + // depth section-locally so the section's own `[` / `]` define its zero point. + // リグレッション (issue #293 補足): `void M([FromServices] IService s)` のような + // 引数なしパラメータ属性も引き続き `attribute` として取り込まれること。 + const string content = """ + public class S + { + public void M([FromServices] IService s) { } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var fromServices = Assert.Single(references.Where(r => r.SymbolName == "FromServices")); + Assert.Equal("attribute", fromServices.ReferenceKind); + Assert.DoesNotContain(references, r => r.SymbolName == "FromServices" && r.ReferenceKind == "call"); + } + + [Fact] + public void Extract_CsharpNoArgDelegateAndLambdaParameterAttributes_ClassifiedAsAttribute() + { + // Regression (issue #293 follow-up): no-arg attributes on delegate parameters and + // lambda parameters also open their `[` inside outer parens, so they require + // section-local paren-depth tracking for top-level zone detection. + // リグレッション (issue #293 補足): デリゲート・ラムダの仮引数に付く no-arg 属性も + // `(` の中で `[` が開くため、section-local の paren 深さ追跡が必要。 + const string content = """ + public delegate void D([Attr] int x); + public class C + { + public void M() + { + System.Func f = ([Attr] int x) => x; + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + // Both occurrences of `Attr` should be classified as `attribute`, not `call`. + // 2 箇所の `Attr` が `attribute` として分類され、`call` にはならないこと。 + var attrs = references.Where(r => r.SymbolName == "Attr").ToList(); + Assert.Equal(2, attrs.Count); + Assert.All(attrs, r => Assert.Equal("attribute", r.ReferenceKind)); + } + + [Fact] + public void Extract_CsharpMultiLineNoArgAttribute_NonLeadingOpenBracket_ClassifiedAsAttribute() + { + // Regression (issue #293 follow-up): multi-line `[...]` sections that open with `[` + // appearing AFTER other text on the opening line (e.g. `void M([`, `class C<[`, + // `delegate void D([`) must also blank out the interior in SymbolExtractor. Otherwise + // the bare identifier on the interior line is extracted as a phantom `function` + // declaration, and the downstream `definitionNames` guard suppresses the real + // `attribute` reference, silently dropping it from `references --kind attribute`. + // リグレッション (issue #293 補足): 開口行の途中で `[` が開く複数行属性 + // (`void M([`, `class C<[`, `delegate void D([` 等) も SymbolExtractor 側で + // 内部を空白化しなければならない。そうしないと、内部行の裸識別子が phantom な + // `function` 宣言として抽出され、下流の `definitionNames` ガードに食われて + // 本来の `attribute` 参照が `references --kind attribute` から消える。 + const string content = """ + public class Foo + { + public void M([ + FromServices + ] IService s) { } + } + + public class Bar<[ + TypeParamAttr + ] T> + { + } + + public delegate void D([ + DelegateParamAttr + ] int x); + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + // None of the attribute names should be misclassified as phantom function symbols. + // 属性名が phantom な function シンボルとして抽出されていないこと。 + Assert.DoesNotContain(symbols, s => s.Name == "FromServices" && s.Kind == "function"); + Assert.DoesNotContain(symbols, s => s.Name == "TypeParamAttr" && s.Kind == "function"); + Assert.DoesNotContain(symbols, s => s.Name == "DelegateParamAttr" && s.Kind == "function"); + + var fromServices = Assert.Single(references.Where(r => r.SymbolName == "FromServices")); + Assert.Equal("attribute", fromServices.ReferenceKind); + + var typeParamAttr = Assert.Single(references.Where(r => r.SymbolName == "TypeParamAttr")); + Assert.Equal("attribute", typeParamAttr.ReferenceKind); + + var delegateParamAttr = Assert.Single(references.Where(r => r.SymbolName == "DelegateParamAttr")); + Assert.Equal("attribute", delegateParamAttr.ReferenceKind); + } + + [Fact] + public void Extract_CsharpMultiLineAttributeArgumentEnum_NotClassifiedAsAttribute() + { + // Regression (issue #293 follow-up): identifiers appearing inside the argument list of + // a multi-line attribute such as `ConverterStrategy.AllowNumbers` must NOT be recorded + // as `attribute` references. Only the attribute-list top level (`[`/`,` boundary, paren + // depth 0) is a valid no-arg attribute name position. + // リグレッション (issue #293 補足): 複数行属性の引数リスト内にある識別子 + // (例: `ConverterStrategy.AllowNumbers`) は `attribute` として記録してはならない。 + // 属性リストの top-level (paren 深さ 0 の `[` / `,` 境界) のみが no-arg 属性名の位置。 + const string content = """ + [ + JsonConverter( + ConverterStrategy.AllowNumbers + ) + ] + public class A + { + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + // JsonConverter is the only attribute here (with-args, classified by the metadata path). + var jsonConverter = Assert.Single(references.Where(r => r.SymbolName == "JsonConverter")); + Assert.Equal("attribute", jsonConverter.ReferenceKind); + + // AllowNumbers is an enum member access inside the attribute arguments — it must not be + // picked up as a no-arg attribute even though it happens to end at end-of-line inside + // the `[...]` section. + // AllowNumbers は属性引数内の enum メンバーアクセスなので、no-arg 属性として取り込まれないこと。 + Assert.DoesNotContain(references, r => r.SymbolName == "AllowNumbers" && r.ReferenceKind == "attribute"); + Assert.DoesNotContain(references, r => r.SymbolName == "ConverterStrategy" && r.ReferenceKind == "attribute"); + } + + [Fact] + public void Extract_CsharpAliasQualifiedNoArgAttribute_ClassifiedAsAttribute() + { + // Regression (issue #293 follow-up): `[Alias::MyAttr]` — alias-qualified attribute. + // The qualifier separator may be `::` (extern alias) as well as `.`; the name segment + // must still be emitted with kind `attribute`. + // リグレッション (issue #293 補足): `[Alias::MyAttr]` のように extern alias 修飾された + // 引数なし属性も `attribute` として取り込まれること。 + const string content = """ + [Alias::MyAttr] + public class C + { + } + """; + + var references = ReferenceExtractor.Extract(1, "csharp", content, []); + + var attr = Assert.Single(references.Where(r => r.SymbolName == "MyAttr")); + Assert.Equal("attribute", attr.ReferenceKind); + Assert.DoesNotContain(references, r => r.ReferenceKind == "call"); + } + + [Fact] + public void Extract_JavaScriptDecorator_ClassifiedAsAnnotation() + { + // Regression (issue #293 follow-up): JavaScript is a graph-supported language, so + // its `@Decorator` / `@Decorator()` forms must be reclassified to `annotation` instead + // of leaking into call-graph edges. Both bare `@sealed` (no-arg, via the dedicated + // regex) and `@injectable()` (via CallRegex + TryClassifyMetadataReference) must end + // up as `annotation`. + // リグレッション (issue #293 補足): JavaScript も graph 対応言語なので、`@Decorator` + // / `@Decorator()` は `annotation` に再分類され、call graph を汚染しないこと。 + const string content = """ + @sealed + @injectable() + class Foo {} + """; + + var references = ReferenceExtractor.Extract(1, "javascript", content, []); + + var sealedRef = Assert.Single(references.Where(r => r.SymbolName == "sealed")); + Assert.Equal("annotation", sealedRef.ReferenceKind); + var injectable = Assert.Single(references.Where(r => r.SymbolName == "injectable")); + Assert.Equal("annotation", injectable.ReferenceKind); + Assert.DoesNotContain(references, r => r.ReferenceKind == "call"); + } + + [Fact] + public void Extract_KotlinQualifiedFieldTargetAnnotation_ClassifiedAsAnnotation() + { + // issue #293 follow-up: Kotlin use-site target with a fully-qualified annotation + // name, e.g. `@field:com.example.Deprecated("msg")`, must be classified as + // `annotation` — the dotted qualifier chain plus the `target:` prefix must both + // resolve back to `@`. + // issue #293 補足: Kotlin の `@field:com.example.Deprecated("msg")` のように use-site + // target と修飾付き注釈名が組み合わさった場合も `annotation` 判定になること。 + const string content = """ + class Example { + @field:com.example.Deprecated("msg") + val value: Int = 0 + } + """; + + var symbols = SymbolExtractor.Extract(1, "kotlin", content); + var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); + + var deprecated = Assert.Single(references.Where(r => r.SymbolName == "Deprecated")); + Assert.Equal("annotation", deprecated.ReferenceKind); + } + + [Fact] + public void Extract_KotlinQualifiedGetTargetAnnotation_ClassifiedAsAnnotation() + { + // Kotlin `@get:com.fasterxml.jackson.annotation.JsonProperty("x")` — use-site target + // combined with a long qualifier chain must still be `annotation`. + // Kotlin の `@get:com.fasterxml.jackson.annotation.JsonProperty("x")` も `annotation`。 + const string content = """ + class K { + @get:com.fasterxml.jackson.annotation.JsonProperty("x") + val x: Int = 0 + } + """; + + var symbols = SymbolExtractor.Extract(1, "kotlin", content); + var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); + + var jsonProperty = Assert.Single(references.Where(r => r.SymbolName == "JsonProperty")); + Assert.Equal("annotation", jsonProperty.ReferenceKind); + } + + [Fact] + public void Extract_KotlinQualifiedAnnotationWithoutTarget_ClassifiedAsAnnotation() + { + // Regression guard: fully-qualified annotation name without a use-site target, e.g. + // `@org.junit.Test(...)`, must still be `annotation`. + // 退行防止: use-site target のない `@org.junit.Test(...)` も引き続き `annotation`。 + const string content = """ + class K { + @org.junit.Test(expected = Exception::class) + fun run() {} + } + """; + + var symbols = SymbolExtractor.Extract(1, "kotlin", content); + var references = ReferenceExtractor.Extract(1, "kotlin", content, symbols); + + var test = Assert.Single(references.Where(r => r.SymbolName == "Test")); + Assert.Equal("annotation", test.ReferenceKind); + } } diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 475c2fe21d..97690de927 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -5389,6 +5389,34 @@ public void Extract_CSharp_DetectsIndexer() Assert.Equal("string", indexer.ReturnType); } + [Fact] + public void Extract_CSharp_DetectsMultiLineIndexer() + { + // #293 follow-up: `StripMultiLineCSharpAttributeInterior` must only blank + // attribute-position `[`. `public int this[\n int i\n] => _items[i];` opens `[` + // right after the `this` keyword, which is an indexer parameter list, not + // an attribute. If that `[` were blanked, the indexer would silently drop + // out of symbol extraction. + // #293 追加対応: `StripMultiLineCSharpAttributeInterior` は属性位置の `[` だけを + // 空白化する必要がある。`public int this[\n int i\n] => _items[i];` の `[` は + // インデクサのパラメータリストであり属性ではない。ここを空白化するとインデクサが + // シンボル抽出から静かに消える。 + var content = + "public class Collection\n" + + "{\n" + + " private int[] _items = new int[10];\n" + + " public int this[\n" + + " int i\n" + + " ] => _items[i];\n" + + "}"; + var symbols = SymbolExtractor.Extract(1, "csharp", content); + + var indexer = symbols.FirstOrDefault(s => s.Name == "Item"); + Assert.NotNull(indexer); + Assert.Equal("function", indexer.Kind); + Assert.Equal("int", indexer.ReturnType); + } + [Fact] public void Extract_CSharp_DetectsOperatorOverloads() {