@phuc1403/musketeer 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +2 -2
  2. package/manifest.json +12 -3
  3. package/package.json +1 -1
  4. package/src/provisioner/detect.js +17 -2
  5. package/src/resolver.js +8 -3
  6. package/src/schema.js +1 -0
  7. package/src/settings-merger.js +0 -0
  8. package/template/.claude/hooks/inject-naming-rule-into-subagents.cjs +44 -0
  9. package/template/.claude/rules/csharp-identifier-naming.md +77 -0
  10. package/template/.claude/skills/code-review/SKILL.md +4 -1
  11. package/template/.claude/skills/code-review/references/naming-rule-review.md +41 -0
  12. package/template/.claude/skills/logical-components/SKILL.md +23 -18
  13. package/template/.claude/skills/logical-components/references/component-classifier-prompt.md +1 -1
  14. package/template/.claude/skills/logical-components/references/responsibility-agent-prompt.md +1 -1
  15. package/template/.claude/skills/logical-components/scripts/Directory.Build.rsp +1 -0
  16. package/template/.claude/skills/logical-components/scripts/Directory.Build.targets +6 -0
  17. package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchPlanning.cs +2 -1
  18. package/template/.claude/skills/logical-components/scripts/LogicalComponents/BatchesCommand.cs +9 -1
  19. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CandidatesCommand.cs +39 -11
  20. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CitableSource.cs +19 -4
  21. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyBatchesCommand.cs +25 -4
  22. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ClassifyMergeCommand.cs +43 -11
  23. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentDiscovery.cs +20 -6
  24. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentRule.cs +57 -14
  25. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ComponentsFile.cs +20 -2
  26. package/template/.claude/skills/logical-components/scripts/LogicalComponents/CouplingResolver.cs +70 -15
  27. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ExtractCommand.cs +28 -13
  28. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownCodeSpans.cs +72 -0
  29. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MarkdownRenderer.cs +11 -6
  30. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUseResolution.cs +91 -22
  31. package/template/.claude/skills/logical-components/scripts/LogicalComponents/MemberUses.cs +85 -9
  32. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PlainText.cs +55 -0
  33. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ProjectFilters.cs +3 -2
  34. package/template/.claude/skills/logical-components/scripts/LogicalComponents/PruneCommand.cs +36 -4
  35. package/template/.claude/skills/logical-components/scripts/LogicalComponents/RenderCommand.cs +75 -14
  36. package/template/.claude/skills/logical-components/scripts/LogicalComponents/ResponsibilityValidation.cs +45 -26
  37. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SeamDispatch.cs +56 -0
  38. package/template/.claude/skills/logical-components/scripts/LogicalComponents/SourceCodeLines.cs +79 -0
  39. package/template/.claude/skills/logical-components/scripts/LogicalComponents/TypeMap.cs +31 -0
  40. package/template/.claude/skills/logical-components/scripts/LogicalComponents/WorkspaceLoader.cs +86 -16
  41. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchPlanningTests.cs +8 -0
  42. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/BatchesCommandTests.cs +37 -0
  43. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CandidatesCommandTests.cs +25 -0
  44. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ClassifyCommandsTests.cs +73 -0
  45. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ComponentDiscoveryTests.cs +63 -0
  46. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppExtractionTests.cs +188 -0
  47. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingAppWorkspace.cs +30 -0
  48. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/CouplingExtractionTests.cs +1 -1
  49. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/FixturePaths.cs +4 -0
  50. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/MarkdownRendererTests.cs +45 -2
  51. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ProjectFiltersTests.cs +4 -0
  52. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/PruneCommandTests.cs +30 -0
  53. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderCommandTests.cs +66 -3
  54. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/RenderTestData.cs +19 -13
  55. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/ResponsibilityValidationTests.cs +89 -1
  56. package/template/.claude/skills/logical-components/scripts/LogicalComponents.Tests/WorkspaceLoaderTests.cs +181 -2
  57. package/template/.claude/skills/logical-components/scripts/NuGet.config +17 -0
  58. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/CompositionRootApp.slnx +5 -0
  59. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderExtensions.cs +15 -0
  60. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/HostBuilderShims.cs +25 -0
  61. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/NativeInterop.cs +17 -0
  62. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/Root.Application.csproj +7 -0
  63. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootAutofacModule.cs +11 -0
  64. package/template/.claude/skills/logical-components/scripts/fixtures/CompositionRootApp/src/Root.Application/RootGreeter.cs +7 -0
  65. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/logical-components.json +48 -0
  66. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AbstractMemberCases.cs +27 -0
  67. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/AttributeCases.cs +23 -0
  68. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/CouplingApp.Application.csproj +7 -0
  69. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericBaseCases.cs +14 -0
  70. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/GenericSeamBindingCases.cs +59 -0
  71. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ImplicitCallCases.cs +230 -0
  72. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.First.cs +11 -0
  73. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/PartialInheritCases.Second.cs +14 -0
  74. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/StackOverflowCases.cs +14 -0
  75. package/template/.claude/skills/logical-components/scripts/fixtures/CouplingApp/src/CouplingApp.Application/ViaDisambiguationCases.cs +36 -0
  76. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/MultiTargetApp.slnx +1 -0
  77. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/Multi.Infrastructure.csproj +12 -0
  78. package/template/.claude/skills/logical-components/scripts/fixtures/MultiTargetApp/src/Multi.Infrastructure/SchedulingClient.cs +12 -0
  79. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/ObjOutputApp.slnx +5 -0
  80. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Greeting.cs +7 -0
  81. package/template/.claude/skills/logical-components/scripts/fixtures/ObjOutputApp/src/Obj.Application/Obj.Application.csproj +14 -0
  82. package/template/.claude/skills/logical-components/scripts/fixtures/RenderGolden/components.json +5 -1
@@ -0,0 +1,72 @@
1
+ using System.Text;
2
+
3
+ namespace LogicalComponents;
4
+
5
+ /// <summary>
6
+ /// CommonMark code-span parsing for <see cref="ResponsibilityValidation"/>'s plain-text check: a backtick run of
7
+ /// length n opens a code span, which closes at the next run of exactly length n (a run of a different length inside is
8
+ /// literal content; a run with no matching close is an unclosed delimiter, not a span). A naive `` `[^`]*` `` match
9
+ /// gets this wrong for an escaped or mismatched backtick, which could let HTML or a link hide inside what looks like,
10
+ /// but is not, a real code span.
11
+ /// </summary>
12
+ public static class MarkdownCodeSpans
13
+ {
14
+ /// <summary>The text with every real code span removed, and whether any backtick run was left unmatched.</summary>
15
+ public static (string Outside, bool Unclosed) Strip(string text)
16
+ {
17
+ var outside = new StringBuilder();
18
+ var unclosed = false;
19
+ var i = 0;
20
+ while (i < text.Length)
21
+ {
22
+ if (text[i] != '`')
23
+ {
24
+ outside.Append(text[i]);
25
+ i++;
26
+ continue;
27
+ }
28
+
29
+ var openStart = i;
30
+ while (i < text.Length && text[i] == '`')
31
+ {
32
+ i++;
33
+ }
34
+
35
+ var openLength = i - openStart;
36
+ var closeStart = -1;
37
+ var j = i;
38
+ while (j < text.Length)
39
+ {
40
+ if (text[j] != '`')
41
+ {
42
+ j++;
43
+ continue;
44
+ }
45
+
46
+ var runStart = j;
47
+ while (j < text.Length && text[j] == '`')
48
+ {
49
+ j++;
50
+ }
51
+
52
+ if (j - runStart == openLength)
53
+ {
54
+ closeStart = runStart;
55
+ break;
56
+ }
57
+ }
58
+
59
+ if (closeStart >= 0)
60
+ {
61
+ i = closeStart + openLength;
62
+ }
63
+ else
64
+ {
65
+ unclosed = true;
66
+ outside.Append('`', openLength);
67
+ }
68
+ }
69
+
70
+ return (outside.ToString(), unclosed);
71
+ }
72
+ }
@@ -61,13 +61,18 @@ public static class MarkdownRenderer
61
61
  return md.ToString();
62
62
  }
63
63
 
64
- // Mermaid node labels are double-quoted; a literal quote is written as an entity.
65
- private static string Escape(string label) => label.Replace("\"", "#quot;");
64
+ // Mermaid node labels are double-quoted; a literal quote is written as an entity. Angle brackets are escaped the
65
+ // same way: some renderers treat a label as HTML, and an unescaped `<` in a type or module name could otherwise
66
+ // inject markup into the diagram.
67
+ private static string Escape(string label) => label.Replace("\"", "#quot;").Replace("<", "#lt;").Replace(">", "#gt;");
66
68
 
67
69
  /// <summary>
68
- /// A Mermaid-safe node id: prefixed so a component called <c>end</c>, <c>graph</c> or <c>class</c> is never read as a
69
- /// keyword, and reduced to letters, digits and underscores so a module name with spaces cannot break a line.
70
+ /// A Mermaid-safe node id: prefixed so a component called <c>end</c>, <c>graph</c> or <c>class</c> is never read as
71
+ /// a keyword, and every character that is not an ASCII letter or digit - including a literal underscore, so the
72
+ /// escape below cannot collide with one already in the id - is replaced by <c>_x{hex}_</c>. That makes the mapping
73
+ /// injective (two different ids always get two different node ids), unlike collapsing every such character to a
74
+ /// single <c>_</c>, which let ids as different as "My Module_Queue" and "My-Module.Queue" collide.
70
75
  /// </summary>
71
- private static string NodeId(string id) =>
72
- "n_" + new string(id.Select(c => char.IsAsciiLetterOrDigit(c) ? c : '_').ToArray());
76
+ private static string NodeId(string id) => "n_" + string.Concat(
77
+ id.Select(c => char.IsAsciiLetterOrDigit(c) ? c.ToString() : $"_x{(int)c:x}_"));
73
78
  }
@@ -22,10 +22,19 @@ namespace LogicalComponents;
22
22
  /// </summary>
23
23
  public sealed class MemberUseResolution(LoadedWorkspace loaded, IReadOnlySet<string> components, PortResolution ports)
24
24
  {
25
+ // How many seen-through hops one chain may follow before it is cut off with a warning: real helper chains never
26
+ // run this deep, but a generic helper that instantiates itself one level deeper at every hop (Nest<T> calling
27
+ // Nest<List<T>>) never revisits the same (member, bindings) pair, so the cycle guard below never catches it and the
28
+ // walk would otherwise recurse until the process stack overflows.
29
+ private const int MaxWalkDepth = 200;
30
+
25
31
  public sealed record RawEdge(string From, string To, string Kind, IReadOnlyList<string> Via, string File, int Line);
26
32
 
27
- /// <summary>The edges a component's code leads to, and the helper code it runs on the way (sorted, distinct).</summary>
28
- public sealed record Resolution(List<RawEdge> Edges, List<SourceSpan> Reaches);
33
+ /// <summary>
34
+ /// The edges a component's code leads to, the helper code it runs on the way (sorted, distinct), and warnings about
35
+ /// coupling the walk could not fully resolve.
36
+ /// </summary>
37
+ public sealed record Resolution(List<RawEdge> Edges, List<SourceSpan> Reaches, List<string> Warnings);
29
38
 
30
39
  // Shared by every component's walk: what a piece of syntax uses never changes within one loaded solution.
31
40
  private readonly Dictionary<SyntaxNode, List<MemberUses.MemberUse>> _uses = [];
@@ -33,24 +42,28 @@ public sealed class MemberUseResolution(LoadedWorkspace loaded, IReadOnlySet<str
33
42
 
34
43
  /// <summary>
35
44
  /// The edges from <paramref name="from"/> reached by <paramref name="uses"/>. <paramref name="self"/> holds the
36
- /// component and the base classes whose code counts as its own: uses of those are not coupling.
45
+ /// component and the base classes whose code counts as its own: uses of those are not coupling. <paramref name="map"/>
46
+ /// binds the type parameters of any base class among <paramref name="uses"/>' declaring types to what the component
47
+ /// supplies as its base class construction, so a generic base's own seam calls resolve to the right construction.
37
48
  /// </summary>
38
49
  public async Task<Resolution> ResolveAsync(
39
- string from, IReadOnlySet<string> self, IEnumerable<MemberUses.MemberUse> uses, CancellationToken cancellationToken)
50
+ string from, IReadOnlySet<string> self, IEnumerable<MemberUses.MemberUse> uses, TypeMap map,
51
+ CancellationToken cancellationToken)
40
52
  {
41
53
  var walk = new Walk(from, self);
42
54
  foreach (var use in uses)
43
55
  {
44
- await ResolveUseAsync(use.Member, use.Kind, [], use.File, use.Line, TypeMap.Empty, walk, cancellationToken);
56
+ await ResolveUseAsync(use.Member, use.Kind, [], use.File, use.Line, map, walk, cancellationToken);
45
57
  }
46
58
 
47
59
  return new Resolution(
48
60
  walk.Edges,
49
- walk.Reaches.OrderBy(s => s.File, StringComparer.Ordinal).ThenBy(s => s.Start).ThenBy(s => s.End).ToList());
61
+ walk.Reaches.OrderBy(s => s.File, StringComparer.Ordinal).ThenBy(s => s.Start).ThenBy(s => s.End).ToList(),
62
+ walk.Warnings.Order(StringComparer.Ordinal).ToList());
50
63
  }
51
64
 
52
65
  // One component's walk: members already seen through (for the same type bindings) are not walked again, which also
53
- // ends cycles.
66
+ // ends cycles. Depth guards the chains a cycle can't catch (see MaxWalkDepth).
54
67
  private sealed class Walk(string from, IReadOnlySet<string> self)
55
68
  {
56
69
  public string From { get; } = from;
@@ -58,6 +71,10 @@ public sealed class MemberUseResolution(LoadedWorkspace loaded, IReadOnlySet<str
58
71
  public HashSet<string> Visited { get; } = new(StringComparer.Ordinal);
59
72
  public List<RawEdge> Edges { get; } = [];
60
73
  public HashSet<SourceSpan> Reaches { get; } = [];
74
+ public HashSet<string> Warnings { get; } = new(StringComparer.Ordinal);
75
+ public int Depth { get; set; }
76
+
77
+ public void Warn(string message) => Warnings.Add(message);
61
78
  }
62
79
 
63
80
  private async Task ResolveUseAsync(
@@ -96,7 +113,21 @@ public sealed class MemberUseResolution(LoadedWorkspace loaded, IReadOnlySet<str
96
113
 
97
114
  if (EdgeExtraction.IsSeam(declaring) && !member.IsStatic && kind != EdgeExtraction.New)
98
115
  {
99
- foreach (var implementation in await ports.ImplementationsAsync(declaring, cancellationToken))
116
+ // A concrete, non-virtual member of an abstract class cannot be overridden: it runs as written for every
117
+ // subclass (or none), so it is this walk's own behaviour rather than a per-implementation dispatch.
118
+ if (declaring.TypeKind == TypeKind.Class && !IsOverridable(member))
119
+ {
120
+ await SeeThroughAsync(member, kind, via.Add(SymbolNames.FullName(declaring)), file, line, map, walk, cancellationToken);
121
+ return;
122
+ }
123
+
124
+ var implementations = await ports.ImplementationsAsync(declaring, cancellationToken);
125
+ if (implementations.Count == 0 && declaring.TypeKind == TypeKind.Class)
126
+ {
127
+ walk.Warn($"{SymbolNames.FullName(declaring.OriginalDefinition)}.{member.Name} has no concrete subclass in the repo");
128
+ }
129
+
130
+ foreach (var implementation in implementations)
100
131
  {
101
132
  if (SeamDispatch.ImplementationOf(implementation, declaring, member) is not { } runs)
102
133
  {
@@ -106,18 +137,22 @@ public sealed class MemberUseResolution(LoadedWorkspace loaded, IReadOnlySet<str
106
137
  // An inherited default body lives on the seam itself: walk it rather than resolve the seam again.
107
138
  if (runs.ContainingType is { } runsIn && EdgeExtraction.IsSeam(runsIn))
108
139
  {
109
- await SeeThroughAsync(runs, kind, via.Add(declaring.Name), file, line, map, walk, cancellationToken);
140
+ await SeeThroughAsync(runs, kind, via.Add(SymbolNames.FullName(declaring)), file, line, map, walk, cancellationToken);
110
141
  }
111
142
  else
112
143
  {
113
- await ResolveUseAsync(runs, kind, via.Add(declaring.Name), file, line, TypeMap.Empty, walk, cancellationToken);
144
+ // Bind the implementation's own type parameters (an open-generic decorator) and the resolved
145
+ // method's own type parameters (a generic method declared on the seam) to what this use actually
146
+ // supplies, so a seam the implementation calls in turn resolves to the right construction too.
147
+ var binding = SeamDispatch.BindingFor(implementation, declaring, member, runs, map);
148
+ await ResolveUseAsync(runs, kind, via.Add(SymbolNames.FullName(declaring)), file, line, binding, walk, cancellationToken);
114
149
  }
115
150
  }
116
151
 
117
152
  return;
118
153
  }
119
154
 
120
- await SeeThroughAsync(member, kind, via.Add(owner.Name), file, line, map, walk, cancellationToken);
155
+ await SeeThroughAsync(member, kind, via.Add(SymbolNames.FullName(owner)), file, line, map, walk, cancellationToken);
121
156
  }
122
157
 
123
158
  private async Task SeeThroughAsync(
@@ -145,26 +180,60 @@ public sealed class MemberUseResolution(LoadedWorkspace loaded, IReadOnlySet<str
145
180
  return;
146
181
  }
147
182
 
148
- var (roots, runsImplicitBase) = kind == EdgeExtraction.New && member is IMethodSymbol constructor
149
- ? WalkRoots.Construction(constructor)
150
- : (WalkRoots.Body(member), false);
151
- Record(roots, walk);
183
+ // A cycle that never repeats a (member, bindings) pair (a generic helper instantiating itself one level
184
+ // deeper at every hop) would otherwise recurse until the process stack overflows: cut it off instead.
185
+ if (walk.Depth >= MaxWalkDepth)
186
+ {
187
+ walk.Warn($"{walk.From}: coupling walk stopped at depth {MaxWalkDepth} following {member.Name}, possible unbounded generic recursion");
188
+ return;
189
+ }
152
190
 
153
- foreach (var root in roots)
191
+ walk.Depth++;
192
+ try
154
193
  {
155
- foreach (var use in await UsesAsync(root, cancellationToken))
194
+ var (roots, runsImplicitBase) = kind == EdgeExtraction.New && member is IMethodSymbol constructor
195
+ ? WalkRoots.Construction(constructor)
196
+ : (WalkRoots.Body(member), false);
197
+ Record(roots, walk);
198
+
199
+ foreach (var root in roots)
156
200
  {
157
- await ResolveUseAsync(use.Member, use.Kind, via, file, line, inner, walk, cancellationToken);
201
+ foreach (var use in await UsesAsync(root, cancellationToken))
202
+ {
203
+ await ResolveUseAsync(use.Member, use.Kind, via, file, line, inner, walk, cancellationToken);
204
+ }
158
205
  }
159
- }
160
206
 
161
- if (runsImplicitBase
162
- && member.ContainingType.BaseType?.InstanceConstructors.FirstOrDefault(c => c.Parameters.Length == 0) is { } baseConstructor)
207
+ if (runsImplicitBase
208
+ && member.ContainingType.BaseType?.InstanceConstructors.FirstOrDefault(c => c.Parameters.Length == 0) is { } baseConstructor)
209
+ {
210
+ await ResolveUseAsync(baseConstructor, EdgeExtraction.New, via, file, line, inner, walk, cancellationToken);
211
+ }
212
+
213
+ // The static constructor runs once, lazily, before the type's first static member use or instantiation:
214
+ // consistent with a static field's own initializer, which is already walked wherever that field is read.
215
+ if ((kind == EdgeExtraction.Static || kind == EdgeExtraction.New)
216
+ && member.ContainingType?.OriginalDefinition.StaticConstructors.FirstOrDefault(c => !c.IsImplicitlyDeclared)
217
+ is { } staticConstructor)
218
+ {
219
+ await ResolveUseAsync(staticConstructor, EdgeExtraction.Call, via, file, line, inner, walk, cancellationToken);
220
+ }
221
+ }
222
+ finally
163
223
  {
164
- await ResolveUseAsync(baseConstructor, EdgeExtraction.New, via, file, line, inner, walk, cancellationToken);
224
+ walk.Depth--;
165
225
  }
166
226
  }
167
227
 
228
+ /// <summary>A method, property or event that a subclass could give a different body: dispatch, not fixed code.</summary>
229
+ private static bool IsOverridable(ISymbol member) => member switch
230
+ {
231
+ IMethodSymbol method => method.IsVirtual || method.IsAbstract || method.IsOverride,
232
+ IPropertySymbol property => property.IsVirtual || property.IsAbstract || property.IsOverride,
233
+ IEventSymbol @event => @event.IsVirtual || @event.IsAbstract || @event.IsOverride,
234
+ _ => false,
235
+ };
236
+
168
237
  // The walked helper code becomes citable for the component; a parameter declares a value and runs nothing.
169
238
  private void Record(IEnumerable<SyntaxNode> roots, Walk walk)
170
239
  {
@@ -8,17 +8,23 @@ namespace LogicalComponents;
8
8
  /// Reads what a piece of code uses, as members: <c>new</c> (the constructor an object creation, a <c>: this(...)</c> /
9
9
  /// <c>: base(...)</c> initializer or a primary-constructor base call runs), <c>static</c> (a static member, an extension
10
10
  /// method or C# 14 extension member, a user-defined operator or conversion) and <c>call</c> (an instance member used on
11
- /// any receiver, an indexer, the enumerator a <c>foreach</c> runs). A property or indexer use is the accessor that
12
- /// actually runs: a read is the getter, an assignment the setter, a compound assignment or increment both. Only
13
- /// citable source is read, so generator output never contributes. What a use couples to is decided by
14
- /// <see cref="MemberUseResolution"/>.
11
+ /// any receiver, an indexer or conditional-access indexer, the enumerator, deconstruction and <c>MoveNext</c>/<c>Current</c>
12
+ /// a <c>foreach</c> runs, the <c>Add</c> a collection initializer element runs, a deconstruction assignment's
13
+ /// <c>Deconstruct</c>, and the <c>GetAwaiter</c>/<c>IsCompleted</c>/<c>GetResult</c> an <c>await</c> runs). A property or
14
+ /// indexer use is the accessor that actually runs: a read is the getter, an assignment the setter, a compound
15
+ /// assignment or increment both. Attribute names are not uses: an attribute is metadata the compiler reads, not code the
16
+ /// annotated member runs. Only citable source is read, so generator output never contributes. What a use couples to is
17
+ /// decided by <see cref="MemberUseResolution"/>.
15
18
  /// </summary>
16
19
  public static class MemberUses
17
20
  {
18
21
  /// <summary>One use of <see cref="Member"/>, at a repo-relative file and 1-based line.</summary>
19
22
  public sealed record MemberUse(ISymbol Member, string Kind, string File, int Line);
20
23
 
21
- /// <summary>Every member use inside the given syntax nodes, in source order. Nodes outside the repo are skipped.</summary>
24
+ /// <summary>
25
+ /// Every member use inside the given syntax nodes, in source order. Nodes outside the repo, and nodes inside an
26
+ /// attribute list, are skipped.
27
+ /// </summary>
22
28
  public static async Task<List<MemberUse>> ReadAsync(
23
29
  IEnumerable<SyntaxNode> roots, LoadedWorkspace loaded, CancellationToken cancellationToken)
24
30
  {
@@ -33,7 +39,10 @@ public static class MemberUses
33
39
 
34
40
  var model = await document.GetSemanticModelAsync(cancellationToken)
35
41
  ?? throw new InvalidOperationException($"No semantic model for {file}");
36
- foreach (var node in root.DescendantNodesAndSelf())
42
+
43
+ // An attribute name resolves to its constructor, but applying an attribute is not running code: never
44
+ // descend into an attribute list at all, so nothing inside one is ever read as a use.
45
+ foreach (var node in root.DescendantNodesAndSelf(descendant => descendant is not AttributeListSyntax))
37
46
  {
38
47
  var line = node.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
39
48
  uses.AddRange(Uses(node, model, cancellationToken).Select(u => new MemberUse(u.Member, u.Kind, file, line)));
@@ -46,6 +55,7 @@ public static class MemberUses
46
55
  private static IEnumerable<(ISymbol Member, string Kind)> Uses(SyntaxNode node, SemanticModel model, CancellationToken token)
47
56
  {
48
57
  ISymbol? Symbol() => model.GetSymbolInfo(node, token).Symbol;
58
+ (ISymbol, string) AsUse(ISymbol member) => (member, member.IsStatic ? EdgeExtraction.Static : EdgeExtraction.Call);
49
59
 
50
60
  switch (node)
51
61
  {
@@ -63,7 +73,7 @@ public static class MemberUses
63
73
  }
64
74
 
65
75
  break;
66
- case ElementAccessExpressionSyntax when Symbol() is IPropertySymbol indexer:
76
+ case ElementAccessExpressionSyntax or ElementBindingExpressionSyntax when Symbol() is IPropertySymbol indexer:
67
77
  foreach (var accessor in AccessorsThatRun(indexer, node))
68
78
  {
69
79
  yield return (accessor, EdgeExtraction.Call);
@@ -75,9 +85,75 @@ public static class MemberUses
75
85
  when Symbol() is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator or MethodKind.Conversion } op:
76
86
  yield return (op, EdgeExtraction.Static);
77
87
  break;
78
- case CommonForEachStatementSyntax loop when model.GetForEachStatementInfo(loop).GetEnumeratorMethod is { } enumerator:
79
- yield return (enumerator, enumerator.IsStatic ? EdgeExtraction.Static : EdgeExtraction.Call);
88
+ // A deconstruction assignment, `(a, b) = point;`: tried before the operator-assignment case above, but only
89
+ // claims the node when it is actually a deconstruction, so a plain or compound assignment still falls
90
+ // through to it.
91
+ case AssignmentExpressionSyntax assignment when DeconstructMethods(model.GetDeconstructionInfo(assignment)).ToList()
92
+ is { Count: > 0 } assignmentDeconstructs:
93
+ foreach (var method in assignmentDeconstructs)
94
+ {
95
+ yield return AsUse(method);
96
+ }
97
+
98
+ break;
99
+ case CommonForEachStatementSyntax loop:
100
+ var info = model.GetForEachStatementInfo(loop);
101
+ foreach (var member in new ISymbol?[] { info.GetEnumeratorMethod, info.MoveNextMethod, info.CurrentProperty?.GetMethod })
102
+ {
103
+ if (member is not null)
104
+ {
105
+ yield return AsUse(member);
106
+ }
107
+ }
108
+
109
+ // foreach (var (a, b) in pairs): the per-element deconstruction, same as an explicit assignment's.
110
+ if (loop is ForEachVariableStatementSyntax deconstructingLoop)
111
+ {
112
+ foreach (var method in DeconstructMethods(model.GetDeconstructionInfo(deconstructingLoop)))
113
+ {
114
+ yield return AsUse(method);
115
+ }
116
+ }
117
+
80
118
  break;
119
+ case InitializerExpressionSyntax initializer when initializer.IsKind(SyntaxKind.CollectionInitializerExpression):
120
+ foreach (var element in initializer.Expressions)
121
+ {
122
+ if (model.GetCollectionInitializerSymbolInfo(element).Symbol is IMethodSymbol add)
123
+ {
124
+ yield return AsUse(add);
125
+ }
126
+ }
127
+
128
+ break;
129
+ case AwaitExpressionSyntax awaitExpression:
130
+ var awaited = model.GetAwaitExpressionInfo(awaitExpression);
131
+ foreach (var member in new ISymbol?[] { awaited.GetAwaiterMethod, awaited.IsCompletedProperty?.GetMethod, awaited.GetResultMethod })
132
+ {
133
+ if (member is not null)
134
+ {
135
+ yield return AsUse(member);
136
+ }
137
+ }
138
+
139
+ break;
140
+ }
141
+ }
142
+
143
+ /// <summary>The Deconstruct methods a deconstruction runs, including any nested tuple/pattern's own.</summary>
144
+ private static IEnumerable<IMethodSymbol> DeconstructMethods(DeconstructionInfo info)
145
+ {
146
+ if (info.Method is { } method)
147
+ {
148
+ yield return method;
149
+ }
150
+
151
+ foreach (var nested in info.Nested)
152
+ {
153
+ foreach (var method2 in DeconstructMethods(nested))
154
+ {
155
+ yield return method2;
156
+ }
81
157
  }
82
158
  }
83
159
 
@@ -0,0 +1,55 @@
1
+ using System.Globalization;
2
+ using System.Text;
3
+
4
+ namespace LogicalComponents;
5
+
6
+ /// <summary>
7
+ /// The unicode rule shared by every short string an agent hands back that is stored verbatim or shown at a review
8
+ /// gate: a classify verdict's <c>reason</c> (<see cref="ClassifyMergeCommand"/>) and a responsibility's <c>text</c>
9
+ /// (<see cref="ResponsibilityValidation"/>). Neither may contain a character that could hide something from a
10
+ /// reviewer or a renderer - a bidi override, a zero-width character, or one that fakes a line break.
11
+ /// </summary>
12
+ public static class PlainText
13
+ {
14
+ /// <summary>
15
+ /// True for text containing a Unicode category that must never appear in plain text stored or shown verbatim:
16
+ /// control (Cc), format (Cf - includes bidi overrides like U+202E and zero-width characters), line/paragraph
17
+ /// separators (Zl/Zp - could fake a second line), private-use (Co), or a lone surrogate (Cs).
18
+ /// </summary>
19
+ public static bool HasDisallowedUnicode(string text)
20
+ {
21
+ for (var i = 0; i < text.Length; i++)
22
+ {
23
+ var c = text[i];
24
+ if (char.IsHighSurrogate(c))
25
+ {
26
+ if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1]))
27
+ {
28
+ return true; // lone high surrogate
29
+ }
30
+
31
+ if (IsBannedCategory(Rune.GetUnicodeCategory(new Rune(c, text[i + 1]))))
32
+ {
33
+ return true;
34
+ }
35
+
36
+ i++;
37
+ continue;
38
+ }
39
+
40
+ if (char.IsLowSurrogate(c) || IsDisallowed(c))
41
+ {
42
+ return true;
43
+ }
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ /// <summary>True for one UTF-16 code unit in a category <see cref="HasDisallowedUnicode"/> rejects.</summary>
50
+ public static bool IsDisallowed(char c) => IsBannedCategory(CharUnicodeInfo.GetUnicodeCategory(c));
51
+
52
+ private static bool IsBannedCategory(UnicodeCategory category) => category is UnicodeCategory.Control
53
+ or UnicodeCategory.Format or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator
54
+ or UnicodeCategory.PrivateUse or UnicodeCategory.Surrogate;
55
+ }
@@ -62,8 +62,9 @@ public static partial class ProjectFilters
62
62
  private static Version ParseVersion(string text) =>
63
63
  Version.TryParse(text.Contains('.') ? text : text + ".0", out var version) ? version : new Version(0, 0);
64
64
 
65
- // Case-sensitive on purpose: "Foo.Contest" and "Latest.Feeds" must not look like test projects.
66
- [GeneratedRegex(@"\.\w*Tests?$|\.Specs?$")]
65
+ // Segment-based and case-sensitive on purpose: "Foo.Contest", "Latest.Feeds", "Net.SpeedTest" and
66
+ // "Experiments.ABTest" must not look like test projects; only these exact last-segment suffixes do.
67
+ [GeneratedRegex(@"\.(Test|Tests|UnitTests|IntegrationTests|FunctionalTests|Spec|Specs)$")]
67
68
  private static partial Regex TestProjectName();
68
69
 
69
70
  [GeneratedRegex(
@@ -6,8 +6,10 @@ namespace LogicalComponents;
6
6
  /// <c>prune --resp &lt;dir&gt; --verify &lt;dir&gt; --out &lt;dir&gt;</c>: applies the verifier agents' verdicts. For every
7
7
  /// <c>resp/batch-NN.json</c> it reads <c>verify/batch-NN.json</c> — <c>{ "&lt;id&gt;": [ { "item": 1, "supported": true,
8
8
  /// "reason": "..." } ] }</c> — and writes the batch without its unsupported items to <c>--out</c>. The agents' files are
9
- /// never edited, so pruning is repeatable. Every item needs exactly one verdict: an unjudged claim is not verified, so
10
- /// any gap fails the step (exit 3) before anything is written. Exit codes: 0 written, 1 usage, 3 verdicts incomplete.
9
+ /// never edited, so pruning is repeatable. Every item needs exactly one verdict: an unjudged claim is not verified, a
10
+ /// null verdict entry or one missing <c>supported</c> is malformed (never silently "not supported"), so any gap or
11
+ /// malformed entry fails the step (exit 3) before anything is written. Exit codes: 0 written, 1 usage, 3 verdicts
12
+ /// incomplete or malformed.
11
13
  /// </summary>
12
14
  public static class PruneCommand
13
15
  {
@@ -15,6 +17,11 @@ public static class PruneCommand
15
17
 
16
18
  public sealed record Verdict(int Item, bool Supported, string? Reason);
17
19
 
20
+ // Supported is nullable only for reading: a verifier file missing it, or a null array entry, is malformed input
21
+ // (exit 3) rather than a silent "not supported". Verdict above keeps Supported non-nullable for every other use,
22
+ // once ReadVerdicts has confirmed it is actually there.
23
+ private sealed record RawVerdict(int Item, bool? Supported, string? Reason);
24
+
18
25
  private static readonly JsonSerializerOptions ReadOptions = new()
19
26
  {
20
27
  PropertyNameCaseInsensitive = true,
@@ -118,15 +125,40 @@ public static class PruneCommand
118
125
  return null;
119
126
  }
120
127
 
128
+ Dictionary<string, List<RawVerdict?>?>? raw;
121
129
  try
122
130
  {
123
- var verdicts = JsonSerializer.Deserialize<Dictionary<string, List<Verdict>?>>(File.ReadAllText(path), ReadOptions) ?? [];
124
- return verdicts.ToDictionary(v => v.Key, v => v.Value ?? [], StringComparer.Ordinal);
131
+ raw = JsonSerializer.Deserialize<Dictionary<string, List<RawVerdict?>?>>(File.ReadAllText(path), ReadOptions);
125
132
  }
126
133
  catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException)
127
134
  {
128
135
  problems.Add($"{name}: not a valid verdict file ({e.Message})");
129
136
  return null;
130
137
  }
138
+
139
+ var clean = new Dictionary<string, List<Verdict>>(StringComparer.Ordinal);
140
+ foreach (var (id, items) in raw ?? [])
141
+ {
142
+ var kept = new List<Verdict>();
143
+ foreach (var verdict in items ?? [])
144
+ {
145
+ if (verdict is null)
146
+ {
147
+ problems.Add($"{id}: a null verdict entry ({name})");
148
+ }
149
+ else if (verdict.Supported is not { } supported)
150
+ {
151
+ problems.Add($"{id}[{verdict.Item}]: verdict is missing 'supported' ({name})");
152
+ }
153
+ else
154
+ {
155
+ kept.Add(new Verdict(verdict.Item, supported, verdict.Reason));
156
+ }
157
+ }
158
+
159
+ clean[id] = kept;
160
+ }
161
+
162
+ return clean;
131
163
  }
132
164
  }