Skip to content

Commit 546e4ec

Browse files
authored
Merge pull request #906 from Scriptwonder/fix/duplicate-detection-false-positives
[bug-fix]Duplicate signature fix
2 parents 096ea32 + 2aba867 commit 546e4ec

3 files changed

Lines changed: 321 additions & 1 deletion

File tree

MCPForUnity/Editor/Tools/ManageScript.cs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2276,6 +2276,9 @@ private static bool ValidateScriptSyntax(string contents, ValidationLevel level,
22762276
return false;
22772277
}
22782278

2279+
// Basic structural validation: check for duplicate method signatures
2280+
CheckDuplicateMethodSignatures(contents, errorList);
2281+
22792282
#if USE_ROSLYN
22802283
// Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors
22812284
if (level >= ValidationLevel.Standard)
@@ -2518,6 +2521,57 @@ private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel
25182521
}
25192522
#endif
25202523

2524+
private static int CountTopLevelParams(string paramStr)
2525+
{
2526+
if (string.IsNullOrWhiteSpace(paramStr)) return 0;
2527+
int depth = 0, count = 1;
2528+
foreach (char c in paramStr)
2529+
{
2530+
if (c == '<' || c == '(' || c == '[') depth++;
2531+
else if (c == '>' || c == ')' || c == ']') depth--;
2532+
else if (c == ',' && depth == 0) count++;
2533+
}
2534+
return count;
2535+
}
2536+
2537+
/// <summary>
2538+
/// Extracts only the type portions from a parameter list, dropping parameter names.
2539+
/// e.g. "Dictionary&lt;string, int&gt; data, List&lt;Vector3&gt; points" → "Dictionary&lt;string, int&gt;, List&lt;Vector3&gt;"
2540+
/// </summary>
2541+
private static string ExtractParamTypes(string paramStr)
2542+
{
2543+
if (string.IsNullOrWhiteSpace(paramStr)) return "";
2544+
var types = new System.Text.StringBuilder();
2545+
// Split at top-level commas (respecting <> depth)
2546+
int depth = 0, start = 0;
2547+
for (int i = 0; i <= paramStr.Length; i++)
2548+
{
2549+
char c = i < paramStr.Length ? paramStr[i] : ','; // sentinel
2550+
if (c == '<' || c == '(' || c == '[') depth++;
2551+
else if (c == '>' || c == ')' || c == ']') depth--;
2552+
else if (c == ',' && depth == 0)
2553+
{
2554+
string param = paramStr.Substring(start, i - start).Trim();
2555+
if (types.Length > 0) types.Append(", ");
2556+
// The type is everything except the last token (the name).
2557+
// But if the last token ends with '>' or ']', it's all type (e.g. "List<int>").
2558+
// Find the last whitespace that is NOT inside <> brackets.
2559+
int lastSplit = -1;
2560+
int d2 = 0;
2561+
for (int j = 0; j < param.Length; j++)
2562+
{
2563+
char pc = param[j];
2564+
if (pc == '<' || pc == '(' || pc == '[') d2++;
2565+
else if (pc == '>' || pc == ')' || pc == ']') d2--;
2566+
else if (d2 == 0 && char.IsWhiteSpace(pc)) lastSplit = j;
2567+
}
2568+
types.Append(lastSplit > 0 ? param.Substring(0, lastSplit).Trim() : param);
2569+
start = i + 1;
2570+
}
2571+
}
2572+
return Regex.Replace(types.ToString(), @"\s+", " ");
2573+
}
2574+
25212575
/// <summary>
25222576
/// Validates Unity-specific coding rules and best practices
25232577
/// //TODO: Naive Unity Checks and not really yield any results, need to be improved
@@ -2581,6 +2635,121 @@ private static void ValidateScriptSyntaxUnity(string contents, System.Collection
25812635
{
25822636
errors.Add("WARNING: String concatenation in Update() can cause garbage collection issues");
25832637
}
2638+
2639+
}
2640+
2641+
/// <summary>
2642+
/// Checks for duplicate method signatures (same name + param types + scope).
2643+
/// Catches corruption from anchor_replace overshoot and similar edit mistakes.
2644+
/// Runs at Basic level since duplicates are structural errors, not style warnings.
2645+
/// </summary>
2646+
private static void CheckDuplicateMethodSignatures(string contents, System.Collections.Generic.List<string> errors)
2647+
{
2648+
// Step 1: Build a code-only view (comments/strings replaced with spaces)
2649+
var codeChars = contents.ToCharArray();
2650+
{
2651+
var lexer = new CSharpLexer(contents);
2652+
while (true)
2653+
{
2654+
int startPos = lexer.Position;
2655+
if (!lexer.Advance(out _)) break;
2656+
if (lexer.InNonCode)
2657+
{
2658+
for (int i = startPos; i < lexer.Position && i < codeChars.Length; i++)
2659+
if (codeChars[i] != '\n') codeChars[i] = ' ';
2660+
}
2661+
}
2662+
}
2663+
var codeOnly = new string(codeChars);
2664+
2665+
// Step 2: Build containing type name at each position via single pass
2666+
var typePattern = new Regex(
2667+
@"\b(?:class|struct|interface|record)\s+(\w+)",
2668+
RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2));
2669+
// Map opening-brace position -> fully qualified type name
2670+
var typeBraceMap = new System.Collections.Generic.Dictionary<int, string>();
2671+
{
2672+
var typeMatches = typePattern.Matches(codeOnly);
2673+
// Pre-scan: for each type declaration, find its opening brace and record full name
2674+
// We need to process in order and track nesting, so do a single forward pass
2675+
var preStack = new System.Collections.Generic.List<(string name, int openDepth)>();
2676+
int preBd = 0;
2677+
int nextTm = 0;
2678+
for (int i = 0; i < codeOnly.Length; i++)
2679+
{
2680+
while (nextTm < typeMatches.Count && typeMatches[nextTm].Index == i)
2681+
{
2682+
int bp = codeOnly.IndexOf('{', typeMatches[nextTm].Index + typeMatches[nextTm].Length);
2683+
if (bp >= 0)
2684+
{
2685+
string tn = typeMatches[nextTm].Groups[1].Value;
2686+
string fn = preStack.Count > 0 ? preStack[preStack.Count - 1].name + "." + tn : tn;
2687+
typeBraceMap[bp] = fn;
2688+
}
2689+
nextTm++;
2690+
}
2691+
if (codeOnly[i] == '{')
2692+
{
2693+
if (typeBraceMap.TryGetValue(i, out string mappedType))
2694+
preStack.Add((mappedType, preBd));
2695+
preBd++;
2696+
}
2697+
else if (codeOnly[i] == '}')
2698+
{
2699+
preBd = Math.Max(0, preBd - 1);
2700+
if (preStack.Count > 0 && preStack[preStack.Count - 1].openDepth == preBd)
2701+
preStack.RemoveAt(preStack.Count - 1);
2702+
}
2703+
}
2704+
}
2705+
// Second pass: build per-position containing type array
2706+
var containingTypeArr = new string[codeOnly.Length];
2707+
{
2708+
var stack = new System.Collections.Generic.List<(string name, int openDepth)>();
2709+
int bd2 = 0;
2710+
string current = "";
2711+
for (int i = 0; i < codeOnly.Length; i++)
2712+
{
2713+
if (codeOnly[i] == '{')
2714+
{
2715+
if (typeBraceMap.TryGetValue(i, out string tn))
2716+
{
2717+
stack.Add((tn, bd2));
2718+
current = tn;
2719+
}
2720+
bd2++;
2721+
}
2722+
else if (codeOnly[i] == '}')
2723+
{
2724+
bd2 = Math.Max(0, bd2 - 1);
2725+
if (stack.Count > 0 && stack[stack.Count - 1].openDepth == bd2)
2726+
{
2727+
stack.RemoveAt(stack.Count - 1);
2728+
current = stack.Count > 0 ? stack[stack.Count - 1].name : "";
2729+
}
2730+
}
2731+
containingTypeArr[i] = current;
2732+
}
2733+
}
2734+
2735+
// Step 3: Match method signatures on code-only text (includes => for expression-bodied)
2736+
var methodSigPattern = new Regex(
2737+
@"(?:(?:public|private|protected|internal)\s+)?(?:(?:static|virtual|override|abstract|sealed|async|new)\s+)*\S+\s+(\w+)\s*\(([^)]*)\)\s*(?:where\s+\S+\s*:\s*\S+\s*)?(?:[{;]|=>)",
2738+
RegexOptions.Multiline | RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2));
2739+
var sigMatches = methodSigPattern.Matches(codeOnly);
2740+
var seen = new System.Collections.Generic.Dictionary<string, int>(System.StringComparer.Ordinal);
2741+
foreach (Match sm in sigMatches)
2742+
{
2743+
string methodName = sm.Groups[1].Value;
2744+
int paramCount = CountTopLevelParams(sm.Groups[2].Value);
2745+
string paramTypes = ExtractParamTypes(sm.Groups[2].Value);
2746+
string containingType = containingTypeArr[sm.Index];
2747+
string key = $"{containingType}/{methodName}/{paramCount}/{paramTypes}";
2748+
if (seen.TryGetValue(key, out _))
2749+
errors.Add($"ERROR: Duplicate method signature detected: '{methodName}' with {paramCount} parameter(s). This may indicate a corrupted edit.");
2750+
else
2751+
seen[key] = 1;
2752+
}
25842753
}
25852754

25862755
/// <summary>

Server/src/services/tools/script_apply_edits.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,7 @@ def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rew
678678
- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
679679
- Avoid whole-file regex deletes; validators will guard unbalanced braces
680680
- For tail insertions, prefer anchor/regex_replace on final brace (class closing)
681-
- Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits
681+
- Pass options.validate='standard' for structural checks; 'basic' for interior-only edits
682682
Canonical fields (use these exact keys):
683683
- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace
684684
- className: string (defaults to 'name' if omitted on method/class ops)

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
24
using NUnit.Framework;
35
using UnityEngine;
46
using Newtonsoft.Json.Linq;
@@ -135,5 +137,154 @@ private bool BasicBalanceCheck(string contents)
135137

136138
return braceCount == 0;
137139
}
140+
141+
/// <summary>
142+
/// Calls ValidateScriptSyntax via reflection and returns the error list.
143+
/// This exercises CheckDuplicateMethodSignatures (called from ValidateScriptSyntax).
144+
/// </summary>
145+
private List<string> CallValidateScriptSyntaxUnity(string contents)
146+
{
147+
var validationLevelType = typeof(ManageScript).GetNestedType("ValidationLevel",
148+
BindingFlags.NonPublic);
149+
Assert.IsNotNull(validationLevelType, "ValidationLevel enum must exist");
150+
var basicLevel = Enum.ToObject(validationLevelType, 0); // ValidationLevel.Basic
151+
152+
var method = typeof(ManageScript).GetMethod("ValidateScriptSyntax",
153+
BindingFlags.NonPublic | BindingFlags.Static, null,
154+
new[] { typeof(string), validationLevelType, typeof(string[]).MakeByRefType() }, null);
155+
Assert.IsNotNull(method, "ValidateScriptSyntax method must exist");
156+
157+
var args = new object[] { contents, basicLevel, null };
158+
method.Invoke(null, args);
159+
var errArray = (string[])args[2];
160+
return errArray != null ? errArray.ToList() : new List<string>();
161+
}
162+
163+
private bool HasDuplicateMethodError(List<string> errors)
164+
{
165+
return errors.Any(e => e.Contains("Duplicate method signature detected"));
166+
}
167+
168+
// --- Duplicate method detection: false positive tests ---
169+
170+
[Test]
171+
public void DuplicateDetection_LineCommentedMethod_NotFlagged()
172+
{
173+
string code = @"using UnityEngine;
174+
public class Foo : MonoBehaviour
175+
{
176+
public void DoStuff(int x) { }
177+
// public void DoStuff(int x) { }
178+
}";
179+
var errors = CallValidateScriptSyntaxUnity(code);
180+
Assert.IsFalse(HasDuplicateMethodError(errors),
181+
"A method in a line comment should not be flagged as duplicate");
182+
}
183+
184+
[Test]
185+
public void DuplicateDetection_BlockCommentedMethod_NotFlagged()
186+
{
187+
string code = @"using UnityEngine;
188+
public class Foo : MonoBehaviour
189+
{
190+
public void DoStuff(int x) { }
191+
/* public void DoStuff(int x) { } */
192+
}";
193+
var errors = CallValidateScriptSyntaxUnity(code);
194+
Assert.IsFalse(HasDuplicateMethodError(errors),
195+
"A method in a block comment should not be flagged as duplicate");
196+
}
197+
198+
[Test]
199+
public void DuplicateDetection_InnerClassSameMethod_NotFlagged()
200+
{
201+
string code = @"using UnityEngine;
202+
public class Outer : MonoBehaviour
203+
{
204+
public void Init(int x) { }
205+
206+
private class Inner
207+
{
208+
public void Init(int x) { }
209+
}
210+
}";
211+
var errors = CallValidateScriptSyntaxUnity(code);
212+
Assert.IsFalse(HasDuplicateMethodError(errors),
213+
"Same method name in outer and inner class should not be flagged");
214+
}
215+
216+
[Test]
217+
public void DuplicateDetection_DifferentTypeOverloads_NotFlagged()
218+
{
219+
string code = @"using UnityEngine;
220+
public class Foo : MonoBehaviour
221+
{
222+
public void Process(int x) { }
223+
public void Process(string x) { }
224+
}";
225+
var errors = CallValidateScriptSyntaxUnity(code);
226+
Assert.IsFalse(HasDuplicateMethodError(errors),
227+
"Overloads with different param types but same count should not be flagged");
228+
}
229+
230+
// --- Duplicate method detection: true positive tests ---
231+
232+
[Test]
233+
public void DuplicateDetection_ExpressionBodiedDuplicate_Flagged()
234+
{
235+
string code = @"using UnityEngine;
236+
public class Foo : MonoBehaviour
237+
{
238+
public int GetValue(int x) => x * 2;
239+
public int GetValue(int x) => x * 3;
240+
}";
241+
var errors = CallValidateScriptSyntaxUnity(code);
242+
Assert.IsTrue(HasDuplicateMethodError(errors),
243+
"Expression-bodied duplicate methods should be flagged");
244+
}
245+
246+
[Test]
247+
public void DuplicateDetection_ExactDuplicate_Flagged()
248+
{
249+
string code = @"using UnityEngine;
250+
public class Foo : MonoBehaviour
251+
{
252+
public void DoStuff(int x) { }
253+
public void DoStuff(int x) { }
254+
}";
255+
var errors = CallValidateScriptSyntaxUnity(code);
256+
Assert.IsTrue(HasDuplicateMethodError(errors),
257+
"Exact duplicate methods should be flagged");
258+
}
259+
260+
[Test]
261+
public void DuplicateDetection_SameTypeDifferentParamName_Flagged()
262+
{
263+
// This is the real anchor_replace corruption pattern
264+
string code = @"using UnityEngine;
265+
public class Foo : MonoBehaviour
266+
{
267+
public void Initialize(string name) { }
268+
public void Initialize(string label) { }
269+
}";
270+
var errors = CallValidateScriptSyntaxUnity(code);
271+
Assert.IsTrue(HasDuplicateMethodError(errors),
272+
"Same-type different-name duplicates (corruption pattern) should be flagged");
273+
}
274+
275+
[Test]
276+
public void DuplicateDetection_GenericParamDuplicate_Flagged()
277+
{
278+
string code = @"using UnityEngine;
279+
using System.Collections.Generic;
280+
public class Foo : MonoBehaviour
281+
{
282+
public void Process(Dictionary<string, int> data) { }
283+
public void Process(Dictionary<string, int> other) { }
284+
}";
285+
var errors = CallValidateScriptSyntaxUnity(code);
286+
Assert.IsTrue(HasDuplicateMethodError(errors),
287+
"Generic param duplicates with different names should be flagged");
288+
}
138289
}
139290
}

0 commit comments

Comments
 (0)