watashi-db 0.0.13 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +36 -0
- package/LICENSE +1 -1
- package/README.md +33 -2
- package/cowork-plugin/skills/groom/SKILL.md +51 -15
- package/cowork-plugin/skills/recall/SKILL.md +5 -6
- package/cowork-plugin/skills/reflect/SKILL.md +4 -4
- package/cowork-plugin/skills/remember/SKILL.md +3 -3
- package/cowork-plugin/skills/session-start/SKILL.md +3 -3
- package/dist/config/schema.js +1 -1
- package/dist/constants.d.ts +5 -1
- package/dist/constants.js +19 -3
- package/dist/constants.js.map +1 -1
- package/dist/database/archive.js +6 -6
- package/dist/database/queries-core.d.ts +75 -1
- package/dist/database/queries-core.js +283 -12
- package/dist/database/queries-core.js.map +1 -1
- package/dist/database/queries.d.ts +71 -1
- package/dist/database/queries.js +29 -0
- package/dist/database/queries.js.map +1 -1
- package/dist/database/schema.d.ts +1 -0
- package/dist/database/schema.js +1915 -214
- package/dist/database/schema.js.map +1 -1
- package/dist/embedding/embed-on-write.d.ts +7 -1
- package/dist/embedding/embed-on-write.js +8 -3
- package/dist/embedding/embed-on-write.js.map +1 -1
- package/dist/hook.js +9 -6
- package/dist/hook.js.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/resources/config-guide-content.d.ts +1 -1
- package/dist/resources/config-guide-content.js +2 -2
- package/dist/server-instructions.js +16 -17
- package/dist/server-instructions.js.map +1 -1
- package/dist/server.d.ts +4 -1
- package/dist/server.js +42 -18
- package/dist/server.js.map +1 -1
- package/dist/setup.js +5 -6
- package/dist/setup.js.map +1 -1
- package/dist/store/federation.d.ts +12 -1
- package/dist/store/federation.js +38 -0
- package/dist/store/federation.js.map +1 -1
- package/dist/store/sync-manager.d.ts +1 -1
- package/dist/store/sync-manager.js +9 -9
- package/dist/tools/claim-tools.d.ts +1 -1
- package/dist/tools/claim-tools.js +7 -7
- package/dist/tools/claim-tools.js.map +1 -1
- package/dist/tools/decision-tools.d.ts +1 -1
- package/dist/tools/decision-tools.js +2 -2
- package/dist/tools/decision-tools.js.map +1 -1
- package/dist/tools/episode-tools.d.ts +1 -1
- package/dist/tools/episode-tools.js +2 -2
- package/dist/tools/episode-tools.js.map +1 -1
- package/dist/tools/file-tools.d.ts +3 -0
- package/dist/tools/file-tools.js +347 -0
- package/dist/tools/file-tools.js.map +1 -0
- package/dist/tools/get-tools.d.ts +1 -1
- package/dist/tools/get-tools.js +39 -5
- package/dist/tools/get-tools.js.map +1 -1
- package/dist/tools/knowledge-tools.d.ts +1 -1
- package/dist/tools/knowledge-tools.js +2 -2
- package/dist/tools/knowledge-tools.js.map +1 -1
- package/dist/tools/maintenance-tools.d.ts +1 -1
- package/dist/tools/maintenance-tools.js +38 -6
- package/dist/tools/maintenance-tools.js.map +1 -1
- package/dist/tools/memo-tools.d.ts +7 -11
- package/dist/tools/memo-tools.js +499 -307
- package/dist/tools/memo-tools.js.map +1 -1
- package/dist/tools/query-tools.d.ts +1 -1
- package/dist/tools/query-tools.js +28 -5
- package/dist/tools/query-tools.js.map +1 -1
- package/dist/types.d.ts +370 -48
- package/dist/types.js +124 -16
- package/dist/types.js.map +1 -1
- package/misc/20260316_110841_groom-recipe.md +483 -0
- package/misc/20260316_xaml-testing-library-recipe.md +817 -0
- package/package.json +4 -2
- package/scripts/update-license-version.sh +7 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
# XAML Testing Library — Phase B: Binding 静的検証 実装レシピ
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
WPF アプリは「画面を開いて初めて Binding ミスに気づく」状態が多い。XAML の `{Binding Path}` が ViewModel のプロパティに正しく対応しているかを、**画面表示なし・コンパイル時に静的チェック**するツールを作る。
|
|
6
|
+
|
|
7
|
+
**技術スタック**: C# / .NET 8+ / Roslyn (Microsoft.CodeAnalysis) / System.Xaml
|
|
8
|
+
|
|
9
|
+
**ゴール**: CI で回せる XAML Binding 検証ツールの PoC
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## プロジェクト構成
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
XamlTestLib/
|
|
17
|
+
├── src/
|
|
18
|
+
│ ├── XamlTestLib/ # メインライブラリ
|
|
19
|
+
│ │ ├── XamlTestLib.csproj
|
|
20
|
+
│ │ ├── Parsing/
|
|
21
|
+
│ │ │ ├── XamlBindingExtractor.cs # XAML から Binding 式を抽出
|
|
22
|
+
│ │ │ ├── BindingInfo.cs # Binding のメタデータ
|
|
23
|
+
│ │ │ └── DataContextResolver.cs # View → ViewModel の対応解決
|
|
24
|
+
│ │ ├── Analysis/
|
|
25
|
+
│ │ │ ├── RoslynProjectLoader.cs # .csproj を Roslyn で読み込み
|
|
26
|
+
│ │ │ ├── ViewModelAnalyzer.cs # ViewModel のプロパティ・型を解析
|
|
27
|
+
│ │ │ └── BindingValidator.cs # Binding パスの検証ロジック
|
|
28
|
+
│ │ └── Reporting/
|
|
29
|
+
│ │ ├── ValidationResult.cs # 検証結果モデル
|
|
30
|
+
│ │ └── ConsoleReporter.cs # コンソール出力
|
|
31
|
+
│ ├── XamlTestLib.Cli/ # CLI ツール(dotnet tool)
|
|
32
|
+
│ │ ├── XamlTestLib.Cli.csproj
|
|
33
|
+
│ │ └── Program.cs
|
|
34
|
+
│ └── XamlTestLib.MsBuild/ # MSBuild タスク(将来)
|
|
35
|
+
│ └── XamlTestLib.MsBuild.csproj
|
|
36
|
+
├── tests/
|
|
37
|
+
│ ├── XamlTestLib.Tests/ # ユニットテスト
|
|
38
|
+
│ │ ├── XamlTestLib.Tests.csproj
|
|
39
|
+
│ │ ├── Parsing/
|
|
40
|
+
│ │ │ └── XamlBindingExtractorTests.cs
|
|
41
|
+
│ │ ├── Analysis/
|
|
42
|
+
│ │ │ ├── ViewModelAnalyzerTests.cs
|
|
43
|
+
│ │ │ └── BindingValidatorTests.cs
|
|
44
|
+
│ │ └── TestData/ # テスト用 XAML / ViewModel
|
|
45
|
+
│ │ ├── SimpleView.xaml
|
|
46
|
+
│ │ ├── SimpleViewModel.cs
|
|
47
|
+
│ │ ├── NestedBindingView.xaml
|
|
48
|
+
│ │ ├── ConverterView.xaml
|
|
49
|
+
│ │ └── BrokenBindingView.xaml
|
|
50
|
+
│ └── SampleWpfApp/ # Dog-fooding 対象
|
|
51
|
+
│ ├── SampleWpfApp.csproj
|
|
52
|
+
│ ├── Views/
|
|
53
|
+
│ │ └── MainWindow.xaml
|
|
54
|
+
│ └── ViewModels/
|
|
55
|
+
│ └── MainWindowViewModel.cs
|
|
56
|
+
├── XamlTestLib.sln
|
|
57
|
+
├── .gitignore
|
|
58
|
+
├── CLAUDE.md
|
|
59
|
+
└── README.md
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Step 1: ソリューション初期構成
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
mkdir XamlTestLib && cd XamlTestLib
|
|
68
|
+
dotnet new sln
|
|
69
|
+
|
|
70
|
+
# メインライブラリ
|
|
71
|
+
dotnet new classlib -n XamlTestLib -o src/XamlTestLib -f net8.0
|
|
72
|
+
dotnet sln add src/XamlTestLib
|
|
73
|
+
|
|
74
|
+
# CLI ツール
|
|
75
|
+
dotnet new console -n XamlTestLib.Cli -o src/XamlTestLib.Cli -f net8.0
|
|
76
|
+
dotnet sln add src/XamlTestLib.Cli
|
|
77
|
+
|
|
78
|
+
# テストプロジェクト
|
|
79
|
+
dotnet new xunit -n XamlTestLib.Tests -o tests/XamlTestLib.Tests -f net8.0
|
|
80
|
+
dotnet sln add tests/XamlTestLib.Tests
|
|
81
|
+
|
|
82
|
+
# サンプル WPF アプリ(dog-fooding 用)
|
|
83
|
+
dotnet new wpf -n SampleWpfApp -o tests/SampleWpfApp -f net8.0-windows
|
|
84
|
+
dotnet sln add tests/SampleWpfApp
|
|
85
|
+
|
|
86
|
+
# プロジェクト参照
|
|
87
|
+
dotnet add src/XamlTestLib.Cli reference src/XamlTestLib
|
|
88
|
+
dotnet add tests/XamlTestLib.Tests reference src/XamlTestLib
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### NuGet パッケージ(XamlTestLib.csproj)
|
|
92
|
+
|
|
93
|
+
```xml
|
|
94
|
+
<ItemGroup>
|
|
95
|
+
<!-- Roslyn: C# ソースの意味解析 -->
|
|
96
|
+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
|
|
97
|
+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.9.2" />
|
|
98
|
+
<PackageReference Include="Microsoft.Build.Locator" Version="1.7.8" />
|
|
99
|
+
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.9.2" />
|
|
100
|
+
</ItemGroup>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
> MSBuildLocator は Roslyn が .csproj をロードするために必要。
|
|
104
|
+
> バージョンは最新を確認すること。
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Step 2: XAML パーサー — Binding 式の抽出
|
|
109
|
+
|
|
110
|
+
### `BindingInfo.cs`
|
|
111
|
+
|
|
112
|
+
```csharp
|
|
113
|
+
namespace XamlTestLib.Parsing;
|
|
114
|
+
|
|
115
|
+
/// <summary>Binding 式のメタデータ</summary>
|
|
116
|
+
public record BindingInfo(
|
|
117
|
+
string Path, // Binding Path(例: "UserName", "Items.Count")
|
|
118
|
+
string? Converter, // Converter 名(例: "BoolToVisibility")
|
|
119
|
+
string? ConverterParameter,
|
|
120
|
+
string? StringFormat,
|
|
121
|
+
string? ElementName, // ElementName binding の場合
|
|
122
|
+
string? RelativeSource, // RelativeSource の場合
|
|
123
|
+
string SourceXamlFile, // 元 XAML ファイルパス
|
|
124
|
+
int LineNumber, // XAML 内の行番号
|
|
125
|
+
string ParentElementName, // 親要素名(例: "TextBox")
|
|
126
|
+
string TargetProperty // 対象プロパティ(例: "Text")
|
|
127
|
+
);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `XamlBindingExtractor.cs` — コア実装
|
|
131
|
+
|
|
132
|
+
XAML を XML として読み、`{Binding ...}` を正規表現 + 簡易パーサーで抽出する。
|
|
133
|
+
`System.Xaml.XamlReader` ではなく XML パーサーを使う理由:
|
|
134
|
+
- XamlReader は型解決が必要(アセンブリ参照がないと失敗する)
|
|
135
|
+
- 静的検証の目的では XML レベルで十分
|
|
136
|
+
- エラー耐性が高い(壊れた XAML でも部分的に処理可能)
|
|
137
|
+
|
|
138
|
+
```csharp
|
|
139
|
+
namespace XamlTestLib.Parsing;
|
|
140
|
+
|
|
141
|
+
using System.Text.RegularExpressions;
|
|
142
|
+
using System.Xml.Linq;
|
|
143
|
+
|
|
144
|
+
public class XamlBindingExtractor
|
|
145
|
+
{
|
|
146
|
+
// {Binding Path=XXX, Converter={StaticResource YYY}} のパターン
|
|
147
|
+
// {Binding XXX} の省略形も対応
|
|
148
|
+
private static readonly Regex BindingRegex = new(
|
|
149
|
+
@"\{Binding\s+(?:Path=)?([^,\}]+)(?:,\s*([^}]*))?\}",
|
|
150
|
+
RegexOptions.Compiled);
|
|
151
|
+
|
|
152
|
+
public IReadOnlyList<BindingInfo> Extract(string xamlFilePath)
|
|
153
|
+
{
|
|
154
|
+
var results = new List<BindingInfo>();
|
|
155
|
+
var doc = XDocument.Load(xamlFilePath, LoadOptions.SetLineInfo);
|
|
156
|
+
|
|
157
|
+
foreach (var element in doc.Descendants())
|
|
158
|
+
{
|
|
159
|
+
foreach (var attr in element.Attributes())
|
|
160
|
+
{
|
|
161
|
+
var matches = BindingRegex.Matches(attr.Value);
|
|
162
|
+
foreach (Match match in matches)
|
|
163
|
+
{
|
|
164
|
+
var path = match.Groups[1].Value.Trim();
|
|
165
|
+
var options = match.Groups[2].Success ? match.Groups[2].Value : null;
|
|
166
|
+
|
|
167
|
+
var info = new BindingInfo(
|
|
168
|
+
Path: path,
|
|
169
|
+
Converter: ExtractOption(options, "Converter"),
|
|
170
|
+
ConverterParameter: ExtractOption(options, "ConverterParameter"),
|
|
171
|
+
StringFormat: ExtractOption(options, "StringFormat"),
|
|
172
|
+
ElementName: ExtractOption(options, "ElementName"),
|
|
173
|
+
RelativeSource: ExtractOption(options, "RelativeSource"),
|
|
174
|
+
SourceXamlFile: xamlFilePath,
|
|
175
|
+
LineNumber: ((IXmlLineInfo)element).LineNumber,
|
|
176
|
+
ParentElementName: element.Name.LocalName,
|
|
177
|
+
TargetProperty: attr.Name.LocalName
|
|
178
|
+
);
|
|
179
|
+
results.Add(info);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private static string? ExtractOption(string? options, string key)
|
|
188
|
+
{
|
|
189
|
+
if (options == null) return null;
|
|
190
|
+
var regex = new Regex($@"{key}=([^,\}}]+)");
|
|
191
|
+
var match = regex.Match(options);
|
|
192
|
+
return match.Success ? match.Groups[1].Value.Trim() : null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### 考慮事項
|
|
198
|
+
- `{Binding}` (パスなし = DataContext 自体) → Path="" で記録
|
|
199
|
+
- `{x:Bind}` (UWP/WinUI) → 将来対応、今は `{Binding}` のみ
|
|
200
|
+
- `MultiBinding` → 子の `<Binding>` 要素として出現するので要素走査で対応(Step 2 では後回し)
|
|
201
|
+
- ネストした `{StaticResource}` 内の Binding → 正規表現を改良する必要あり
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Step 3: DataContext 解決 — View と ViewModel の対応付け
|
|
206
|
+
|
|
207
|
+
### 対応付けの3戦略(優先順)
|
|
208
|
+
|
|
209
|
+
1. **d:DataContext 属性**(デザイン時 DataContext)
|
|
210
|
+
```xml
|
|
211
|
+
<Window d:DataContext="{d:DesignInstance vm:MainWindowViewModel}">
|
|
212
|
+
```
|
|
213
|
+
→ 最も信頼性が高い。これがあれば確定。
|
|
214
|
+
|
|
215
|
+
2. **code-behind の DataContext 代入**
|
|
216
|
+
```csharp
|
|
217
|
+
DataContext = new MainWindowViewModel();
|
|
218
|
+
```
|
|
219
|
+
→ Roslyn で code-behind を解析して型を特定。
|
|
220
|
+
|
|
221
|
+
3. **命名規約**
|
|
222
|
+
- `MainWindow.xaml` → `MainWindowViewModel`
|
|
223
|
+
- `UserListView.xaml` → `UserListViewModel`
|
|
224
|
+
→ フォールバック。設定でカスタマイズ可能にする。
|
|
225
|
+
|
|
226
|
+
### `DataContextResolver.cs`
|
|
227
|
+
|
|
228
|
+
```csharp
|
|
229
|
+
namespace XamlTestLib.Parsing;
|
|
230
|
+
|
|
231
|
+
using System.Xml.Linq;
|
|
232
|
+
|
|
233
|
+
public class DataContextResolver
|
|
234
|
+
{
|
|
235
|
+
private static readonly XNamespace DesignNs =
|
|
236
|
+
"http://schemas.microsoft.com/expression/blend/2008";
|
|
237
|
+
|
|
238
|
+
/// <summary>XAML から DataContext 型名を解決する</summary>
|
|
239
|
+
public string? ResolveFromXaml(string xamlFilePath)
|
|
240
|
+
{
|
|
241
|
+
var doc = XDocument.Load(xamlFilePath);
|
|
242
|
+
var root = doc.Root;
|
|
243
|
+
if (root == null) return null;
|
|
244
|
+
|
|
245
|
+
// 戦略1: d:DataContext
|
|
246
|
+
var designContext = root.Attribute(DesignNs + "DataContext");
|
|
247
|
+
if (designContext != null)
|
|
248
|
+
{
|
|
249
|
+
return ExtractDesignInstanceType(designContext.Value, root);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 戦略3: 命名規約(フォールバック)
|
|
253
|
+
var fileName = Path.GetFileNameWithoutExtension(xamlFilePath);
|
|
254
|
+
return fileName + "ViewModel";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private string? ExtractDesignInstanceType(string value, XElement root)
|
|
258
|
+
{
|
|
259
|
+
// {d:DesignInstance vm:MainWindowViewModel} のパターン
|
|
260
|
+
var match = Regex.Match(value, @"DesignInstance\s+(?:Type=)?(\w+:)?(\w+)");
|
|
261
|
+
if (!match.Success) return null;
|
|
262
|
+
|
|
263
|
+
var prefix = match.Groups[1].Value.TrimEnd(':');
|
|
264
|
+
var typeName = match.Groups[2].Value;
|
|
265
|
+
|
|
266
|
+
if (!string.IsNullOrEmpty(prefix))
|
|
267
|
+
{
|
|
268
|
+
// xmlns:vm="clr-namespace:SampleApp.ViewModels" からフル名を解決
|
|
269
|
+
var ns = root.Attributes()
|
|
270
|
+
.FirstOrDefault(a => a.Name.LocalName == prefix)?
|
|
271
|
+
.Value;
|
|
272
|
+
if (ns != null)
|
|
273
|
+
{
|
|
274
|
+
var clrNs = ExtractClrNamespace(ns);
|
|
275
|
+
if (clrNs != null) return $"{clrNs}.{typeName}";
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return typeName;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private static string? ExtractClrNamespace(string xmlns)
|
|
283
|
+
{
|
|
284
|
+
// "clr-namespace:SampleApp.ViewModels;assembly=SampleApp"
|
|
285
|
+
var match = Regex.Match(xmlns, @"clr-namespace:([^;]+)");
|
|
286
|
+
return match.Success ? match.Groups[1].Value : null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Step 4: Roslyn で ViewModel を解析
|
|
294
|
+
|
|
295
|
+
### `RoslynProjectLoader.cs`
|
|
296
|
+
|
|
297
|
+
```csharp
|
|
298
|
+
namespace XamlTestLib.Analysis;
|
|
299
|
+
|
|
300
|
+
using Microsoft.Build.Locator;
|
|
301
|
+
using Microsoft.CodeAnalysis;
|
|
302
|
+
using Microsoft.CodeAnalysis.MSBuild;
|
|
303
|
+
|
|
304
|
+
public class RoslynProjectLoader : IDisposable
|
|
305
|
+
{
|
|
306
|
+
private MSBuildWorkspace? _workspace;
|
|
307
|
+
|
|
308
|
+
static RoslynProjectLoader()
|
|
309
|
+
{
|
|
310
|
+
// MSBuild の場所を自動検出(初回のみ)
|
|
311
|
+
if (!MSBuildLocator.IsRegistered)
|
|
312
|
+
MSBuildLocator.RegisterDefaults();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
public async Task<Compilation?> LoadProjectAsync(string csprojPath)
|
|
316
|
+
{
|
|
317
|
+
_workspace = MSBuildWorkspace.Create();
|
|
318
|
+
var project = await _workspace.OpenProjectAsync(csprojPath);
|
|
319
|
+
return await project.GetCompilationAsync();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
public void Dispose() => _workspace?.Dispose();
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### `ViewModelAnalyzer.cs`
|
|
327
|
+
|
|
328
|
+
```csharp
|
|
329
|
+
namespace XamlTestLib.Analysis;
|
|
330
|
+
|
|
331
|
+
using Microsoft.CodeAnalysis;
|
|
332
|
+
|
|
333
|
+
public class ViewModelAnalyzer
|
|
334
|
+
{
|
|
335
|
+
private readonly Compilation _compilation;
|
|
336
|
+
|
|
337
|
+
public ViewModelAnalyzer(Compilation compilation)
|
|
338
|
+
{
|
|
339
|
+
_compilation = compilation;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/// <summary>型名から INamedTypeSymbol を取得</summary>
|
|
343
|
+
public INamedTypeSymbol? FindType(string fullTypeName)
|
|
344
|
+
{
|
|
345
|
+
return _compilation.GetTypeByMetadataName(fullTypeName);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// <summary>型名の部分一致検索(namespace 不明の場合)</summary>
|
|
349
|
+
public INamedTypeSymbol? FindTypeByName(string typeName)
|
|
350
|
+
{
|
|
351
|
+
return _compilation.GetSymbolsWithName(typeName, SymbolFilter.Type)
|
|
352
|
+
.OfType<INamedTypeSymbol>()
|
|
353
|
+
.FirstOrDefault();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/// <summary>Binding パスが ViewModel 上で有効かチェック</summary>
|
|
357
|
+
public PropertyCheckResult CheckBindingPath(INamedTypeSymbol viewModelType, string bindingPath)
|
|
358
|
+
{
|
|
359
|
+
// "Items.Count" → ["Items", "Count"]
|
|
360
|
+
var segments = bindingPath.Split('.');
|
|
361
|
+
var currentType = viewModelType;
|
|
362
|
+
|
|
363
|
+
foreach (var segment in segments)
|
|
364
|
+
{
|
|
365
|
+
// インデクサ [0] や添付プロパティ (Grid.Row) は今はスキップ
|
|
366
|
+
if (segment.Contains('[') || segment.Contains('('))
|
|
367
|
+
{
|
|
368
|
+
return new PropertyCheckResult(true, null, "Skipped: indexer or attached property");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
var member = FindProperty(currentType, segment);
|
|
372
|
+
if (member == null)
|
|
373
|
+
{
|
|
374
|
+
return new PropertyCheckResult(
|
|
375
|
+
false,
|
|
376
|
+
currentType,
|
|
377
|
+
$"Property '{segment}' not found on type '{currentType.Name}'"
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 次のセグメントのために型を進める
|
|
382
|
+
currentType = GetPropertyType(member);
|
|
383
|
+
if (currentType == null)
|
|
384
|
+
{
|
|
385
|
+
return new PropertyCheckResult(true, null, "Skipped: unresolvable type");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return new PropertyCheckResult(true, currentType, null);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private ISymbol? FindProperty(INamedTypeSymbol type, string name)
|
|
393
|
+
{
|
|
394
|
+
// 本人 + 基底クラスを走査
|
|
395
|
+
var current = type;
|
|
396
|
+
while (current != null)
|
|
397
|
+
{
|
|
398
|
+
var member = current.GetMembers(name)
|
|
399
|
+
.FirstOrDefault(m => m.Kind == SymbolKind.Property
|
|
400
|
+
|| m.Kind == SymbolKind.Field);
|
|
401
|
+
if (member != null) return member;
|
|
402
|
+
current = current.BaseType;
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private INamedTypeSymbol? GetPropertyType(ISymbol member)
|
|
408
|
+
{
|
|
409
|
+
var type = member switch
|
|
410
|
+
{
|
|
411
|
+
IPropertySymbol p => p.Type,
|
|
412
|
+
IFieldSymbol f => f.Type,
|
|
413
|
+
_ => null
|
|
414
|
+
};
|
|
415
|
+
return type as INamedTypeSymbol;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
public record PropertyCheckResult(
|
|
420
|
+
bool IsValid,
|
|
421
|
+
INamedTypeSymbol? ResolvedType,
|
|
422
|
+
string? Message
|
|
423
|
+
);
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## Step 5: Binding 検証の統合
|
|
429
|
+
|
|
430
|
+
### `BindingValidator.cs`
|
|
431
|
+
|
|
432
|
+
```csharp
|
|
433
|
+
namespace XamlTestLib.Analysis;
|
|
434
|
+
|
|
435
|
+
using XamlTestLib.Parsing;
|
|
436
|
+
using XamlTestLib.Reporting;
|
|
437
|
+
|
|
438
|
+
public class BindingValidator
|
|
439
|
+
{
|
|
440
|
+
private readonly ViewModelAnalyzer _analyzer;
|
|
441
|
+
private readonly DataContextResolver _contextResolver;
|
|
442
|
+
|
|
443
|
+
public BindingValidator(ViewModelAnalyzer analyzer)
|
|
444
|
+
{
|
|
445
|
+
_analyzer = analyzer;
|
|
446
|
+
_contextResolver = new DataContextResolver();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
public ValidationReport Validate(IEnumerable<string> xamlFiles)
|
|
450
|
+
{
|
|
451
|
+
var extractor = new XamlBindingExtractor();
|
|
452
|
+
var results = new List<ValidationResult>();
|
|
453
|
+
|
|
454
|
+
foreach (var xamlFile in xamlFiles)
|
|
455
|
+
{
|
|
456
|
+
// 1. Binding 式を抽出
|
|
457
|
+
var bindings = extractor.Extract(xamlFile);
|
|
458
|
+
|
|
459
|
+
// 2. DataContext(ViewModel 型)を解決
|
|
460
|
+
var viewModelName = _contextResolver.ResolveFromXaml(xamlFile);
|
|
461
|
+
if (viewModelName == null)
|
|
462
|
+
{
|
|
463
|
+
results.Add(new ValidationResult(
|
|
464
|
+
xamlFile, 0, Severity.Warning,
|
|
465
|
+
"DataContext を解決できませんでした。d:DataContext または命名規約を確認してください。"
|
|
466
|
+
));
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 3. ViewModel 型を Roslyn で取得
|
|
471
|
+
var vmType = _analyzer.FindTypeByName(viewModelName)
|
|
472
|
+
?? _analyzer.FindType(viewModelName);
|
|
473
|
+
if (vmType == null)
|
|
474
|
+
{
|
|
475
|
+
results.Add(new ValidationResult(
|
|
476
|
+
xamlFile, 0, Severity.Warning,
|
|
477
|
+
$"ViewModel '{viewModelName}' がプロジェクト内に見つかりません。"
|
|
478
|
+
));
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 4. 各 Binding パスを検証
|
|
483
|
+
foreach (var binding in bindings)
|
|
484
|
+
{
|
|
485
|
+
// ElementName / RelativeSource binding はスキップ
|
|
486
|
+
if (binding.ElementName != null || binding.RelativeSource != null)
|
|
487
|
+
continue;
|
|
488
|
+
|
|
489
|
+
// 空パス(DataContext 自体への Binding)はスキップ
|
|
490
|
+
if (string.IsNullOrWhiteSpace(binding.Path))
|
|
491
|
+
continue;
|
|
492
|
+
|
|
493
|
+
var check = _analyzer.CheckBindingPath(vmType, binding.Path);
|
|
494
|
+
if (!check.IsValid)
|
|
495
|
+
{
|
|
496
|
+
results.Add(new ValidationResult(
|
|
497
|
+
xamlFile,
|
|
498
|
+
binding.LineNumber,
|
|
499
|
+
Severity.Error,
|
|
500
|
+
$"{binding.ParentElementName}.{binding.TargetProperty}: "
|
|
501
|
+
+ $"Binding Path=\"{binding.Path}\" — {check.Message}"
|
|
502
|
+
));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return new ValidationReport(results);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### `ValidationResult.cs` / `ValidationReport.cs`
|
|
513
|
+
|
|
514
|
+
```csharp
|
|
515
|
+
namespace XamlTestLib.Reporting;
|
|
516
|
+
|
|
517
|
+
public enum Severity { Info, Warning, Error }
|
|
518
|
+
|
|
519
|
+
public record ValidationResult(
|
|
520
|
+
string FilePath,
|
|
521
|
+
int LineNumber,
|
|
522
|
+
Severity Severity,
|
|
523
|
+
string Message
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
public class ValidationReport
|
|
527
|
+
{
|
|
528
|
+
public IReadOnlyList<ValidationResult> Results { get; }
|
|
529
|
+
public int ErrorCount => Results.Count(r => r.Severity == Severity.Error);
|
|
530
|
+
public int WarningCount => Results.Count(r => r.Severity == Severity.Warning);
|
|
531
|
+
public bool HasErrors => ErrorCount > 0;
|
|
532
|
+
|
|
533
|
+
public ValidationReport(IReadOnlyList<ValidationResult> results)
|
|
534
|
+
{
|
|
535
|
+
Results = results;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## Step 6: CLI ツール
|
|
543
|
+
|
|
544
|
+
### `Program.cs`
|
|
545
|
+
|
|
546
|
+
```csharp
|
|
547
|
+
using XamlTestLib.Analysis;
|
|
548
|
+
|
|
549
|
+
if (args.Length == 0)
|
|
550
|
+
{
|
|
551
|
+
Console.Error.WriteLine("Usage: xaml-test <path-to.csproj>");
|
|
552
|
+
return 1;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
var csprojPath = Path.GetFullPath(args[0]);
|
|
556
|
+
if (!File.Exists(csprojPath))
|
|
557
|
+
{
|
|
558
|
+
Console.Error.WriteLine($"File not found: {csprojPath}");
|
|
559
|
+
return 1;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
Console.WriteLine($"Loading project: {csprojPath}");
|
|
563
|
+
|
|
564
|
+
using var loader = new RoslynProjectLoader();
|
|
565
|
+
var compilation = await loader.LoadProjectAsync(csprojPath);
|
|
566
|
+
if (compilation == null)
|
|
567
|
+
{
|
|
568
|
+
Console.Error.WriteLine("Failed to load project.");
|
|
569
|
+
return 1;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// XAML ファイルを収集
|
|
573
|
+
var projectDir = Path.GetDirectoryName(csprojPath)!;
|
|
574
|
+
var xamlFiles = Directory.GetFiles(projectDir, "*.xaml", SearchOption.AllDirectories)
|
|
575
|
+
.Where(f => !f.Contains("obj") && !f.Contains("bin"))
|
|
576
|
+
.ToList();
|
|
577
|
+
|
|
578
|
+
Console.WriteLine($"Found {xamlFiles.Count} XAML files.");
|
|
579
|
+
|
|
580
|
+
var analyzer = new ViewModelAnalyzer(compilation);
|
|
581
|
+
var validator = new BindingValidator(analyzer);
|
|
582
|
+
var report = validator.Validate(xamlFiles);
|
|
583
|
+
|
|
584
|
+
// 結果出力
|
|
585
|
+
foreach (var result in report.Results)
|
|
586
|
+
{
|
|
587
|
+
var icon = result.Severity switch
|
|
588
|
+
{
|
|
589
|
+
Severity.Error => "ERROR",
|
|
590
|
+
Severity.Warning => "WARN ",
|
|
591
|
+
_ => "INFO "
|
|
592
|
+
};
|
|
593
|
+
Console.WriteLine($" [{icon}] {Path.GetFileName(result.FilePath)}:{result.LineNumber}");
|
|
594
|
+
Console.WriteLine($" {result.Message}");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
Console.WriteLine();
|
|
598
|
+
Console.WriteLine($"Errors: {report.ErrorCount}, Warnings: {report.WarningCount}");
|
|
599
|
+
|
|
600
|
+
return report.HasErrors ? 1 : 0;
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
---
|
|
604
|
+
|
|
605
|
+
## Step 7: テストデータとユニットテスト
|
|
606
|
+
|
|
607
|
+
### `TestData/SimpleViewModel.cs`(テスト用ソース文字列)
|
|
608
|
+
|
|
609
|
+
```csharp
|
|
610
|
+
public class SimpleViewModel
|
|
611
|
+
{
|
|
612
|
+
public string UserName { get; set; }
|
|
613
|
+
public int Age { get; set; }
|
|
614
|
+
public Address Address { get; set; }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
public class Address
|
|
618
|
+
{
|
|
619
|
+
public string City { get; set; }
|
|
620
|
+
public string ZipCode { get; set; }
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
### `XamlBindingExtractorTests.cs`
|
|
625
|
+
|
|
626
|
+
```csharp
|
|
627
|
+
public class XamlBindingExtractorTests
|
|
628
|
+
{
|
|
629
|
+
[Fact]
|
|
630
|
+
public void Extract_SimpleBinding_ReturnsPath()
|
|
631
|
+
{
|
|
632
|
+
var xaml = CreateTempXaml("""
|
|
633
|
+
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
|
634
|
+
<TextBox Text="{Binding UserName}" />
|
|
635
|
+
</Window>
|
|
636
|
+
""");
|
|
637
|
+
|
|
638
|
+
var extractor = new XamlBindingExtractor();
|
|
639
|
+
var bindings = extractor.Extract(xaml);
|
|
640
|
+
|
|
641
|
+
Assert.Single(bindings);
|
|
642
|
+
Assert.Equal("UserName", bindings[0].Path);
|
|
643
|
+
Assert.Equal("TextBox", bindings[0].ParentElementName);
|
|
644
|
+
Assert.Equal("Text", bindings[0].TargetProperty);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
[Fact]
|
|
648
|
+
public void Extract_NestedPath_ReturnsDottedPath()
|
|
649
|
+
{
|
|
650
|
+
var xaml = CreateTempXaml("""
|
|
651
|
+
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
|
652
|
+
<TextBlock Text="{Binding Address.City}" />
|
|
653
|
+
</Window>
|
|
654
|
+
""");
|
|
655
|
+
|
|
656
|
+
var extractor = new XamlBindingExtractor();
|
|
657
|
+
var bindings = extractor.Extract(xaml);
|
|
658
|
+
|
|
659
|
+
Assert.Single(bindings);
|
|
660
|
+
Assert.Equal("Address.City", bindings[0].Path);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
[Fact]
|
|
664
|
+
public void Extract_BrokenBinding_StillParsesValid()
|
|
665
|
+
{
|
|
666
|
+
var xaml = CreateTempXaml("""
|
|
667
|
+
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
|
668
|
+
<TextBox Text="{Binding UserName}" />
|
|
669
|
+
<TextBox Text="{Binding NonExistent}" />
|
|
670
|
+
</Window>
|
|
671
|
+
""");
|
|
672
|
+
|
|
673
|
+
var extractor = new XamlBindingExtractor();
|
|
674
|
+
var bindings = extractor.Extract(xaml);
|
|
675
|
+
|
|
676
|
+
Assert.Equal(2, bindings.Count);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private static string CreateTempXaml(string content)
|
|
680
|
+
{
|
|
681
|
+
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xaml");
|
|
682
|
+
File.WriteAllText(path, content);
|
|
683
|
+
return path;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
### `BindingValidatorTests.cs`(Roslyn インメモリ Compilation)
|
|
689
|
+
|
|
690
|
+
```csharp
|
|
691
|
+
using Microsoft.CodeAnalysis;
|
|
692
|
+
using Microsoft.CodeAnalysis.CSharp;
|
|
693
|
+
|
|
694
|
+
public class BindingValidatorTests
|
|
695
|
+
{
|
|
696
|
+
[Fact]
|
|
697
|
+
public void Validate_ValidBinding_NoErrors()
|
|
698
|
+
{
|
|
699
|
+
// Roslyn でインメモリ Compilation を作成(.csproj 不要)
|
|
700
|
+
var compilation = CreateCompilation("""
|
|
701
|
+
public class MainWindowViewModel
|
|
702
|
+
{
|
|
703
|
+
public string UserName { get; set; }
|
|
704
|
+
}
|
|
705
|
+
""");
|
|
706
|
+
|
|
707
|
+
var analyzer = new ViewModelAnalyzer(compilation);
|
|
708
|
+
var vmType = analyzer.FindTypeByName("MainWindowViewModel");
|
|
709
|
+
|
|
710
|
+
var result = analyzer.CheckBindingPath(vmType!, "UserName");
|
|
711
|
+
Assert.True(result.IsValid);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
[Fact]
|
|
715
|
+
public void Validate_InvalidBinding_ReturnsError()
|
|
716
|
+
{
|
|
717
|
+
var compilation = CreateCompilation("""
|
|
718
|
+
public class MainWindowViewModel
|
|
719
|
+
{
|
|
720
|
+
public string UserName { get; set; }
|
|
721
|
+
}
|
|
722
|
+
""");
|
|
723
|
+
|
|
724
|
+
var analyzer = new ViewModelAnalyzer(compilation);
|
|
725
|
+
var vmType = analyzer.FindTypeByName("MainWindowViewModel");
|
|
726
|
+
|
|
727
|
+
var result = analyzer.CheckBindingPath(vmType!, "NonExistent");
|
|
728
|
+
Assert.False(result.IsValid);
|
|
729
|
+
Assert.Contains("not found", result.Message);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
[Fact]
|
|
733
|
+
public void Validate_NestedPath_ResolvesCorrectly()
|
|
734
|
+
{
|
|
735
|
+
var compilation = CreateCompilation("""
|
|
736
|
+
public class MainWindowViewModel
|
|
737
|
+
{
|
|
738
|
+
public Address Address { get; set; }
|
|
739
|
+
}
|
|
740
|
+
public class Address
|
|
741
|
+
{
|
|
742
|
+
public string City { get; set; }
|
|
743
|
+
}
|
|
744
|
+
""");
|
|
745
|
+
|
|
746
|
+
var analyzer = new ViewModelAnalyzer(compilation);
|
|
747
|
+
var vmType = analyzer.FindTypeByName("MainWindowViewModel");
|
|
748
|
+
|
|
749
|
+
Assert.True(analyzer.CheckBindingPath(vmType!, "Address.City").IsValid);
|
|
750
|
+
Assert.False(analyzer.CheckBindingPath(vmType!, "Address.Country").IsValid);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private static Compilation CreateCompilation(string source)
|
|
754
|
+
{
|
|
755
|
+
var tree = CSharpSyntaxTree.ParseText(source);
|
|
756
|
+
return CSharpCompilation.Create("TestAssembly")
|
|
757
|
+
.AddReferences(MetadataReference.CreateFromFile(
|
|
758
|
+
typeof(object).Assembly.Location))
|
|
759
|
+
.AddSyntaxTrees(tree);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
---
|
|
765
|
+
|
|
766
|
+
## 実装順序(PoC 最短ルート)
|
|
767
|
+
|
|
768
|
+
1. **ソリューション初期構成** — `dotnet new` でプロジェクト群を作成
|
|
769
|
+
2. **BindingInfo + XamlBindingExtractor** — XAML → Binding 式抽出(XML ベース)
|
|
770
|
+
3. **XamlBindingExtractorTests** — テストデータで動作確認
|
|
771
|
+
4. **ViewModelAnalyzer** — Roslyn インメモリ Compilation でプロパティ検証
|
|
772
|
+
5. **BindingValidatorTests** — インメモリ Compilation でユニットテスト通過
|
|
773
|
+
6. **DataContextResolver** — d:DataContext + 命名規約で View → ViewModel 対応
|
|
774
|
+
7. **BindingValidator** — 統合ロジック
|
|
775
|
+
8. **CLI Program.cs** — 実プロジェクト(SampleWpfApp)で E2E 動作確認
|
|
776
|
+
|
|
777
|
+
**PoC ゴール**: SampleWpfApp に意図的に壊れた Binding を入れて、CLI が検出できること。
|
|
778
|
+
|
|
779
|
+
---
|
|
780
|
+
|
|
781
|
+
## 将来の拡張(Phase B+)
|
|
782
|
+
|
|
783
|
+
| 機能 | 概要 | 難易度 |
|
|
784
|
+
|------|------|--------|
|
|
785
|
+
| **DataTemplate 内の Binding** | ItemsControl 系の DataTemplate の DataType から型解決 | 中 |
|
|
786
|
+
| **Converter 存在チェック** | StaticResource で参照される Converter がリソースに存在するか | 中 |
|
|
787
|
+
| **Command 存在チェック** | `Command="{Binding SaveCommand}"` → ICommand プロパティの存在確認 | 低 |
|
|
788
|
+
| **MSBuild タスク** | ビルド時に自動実行、Warning/Error として VS に表示 | 中 |
|
|
789
|
+
| **Roslyn Analyzer** | IDE 内リアルタイム警告(赤波線) | 高 |
|
|
790
|
+
| **x:Bind 対応** | WinUI / UWP 向け | 中 |
|
|
791
|
+
| **CI 用 JSON 出力** | GitHub Actions 等で Annotation として表示 | 低 |
|
|
792
|
+
|
|
793
|
+
---
|
|
794
|
+
|
|
795
|
+
## CLAUDE.md(初期内容案)
|
|
796
|
+
|
|
797
|
+
```markdown
|
|
798
|
+
# XamlTestLib
|
|
799
|
+
|
|
800
|
+
WPF XAML Binding の静的検証ツール。画面表示なしで Binding パスの正当性を検証する。
|
|
801
|
+
|
|
802
|
+
## ビルド・テスト
|
|
803
|
+
|
|
804
|
+
dotnet build
|
|
805
|
+
dotnet test
|
|
806
|
+
|
|
807
|
+
## アーキテクチャ
|
|
808
|
+
|
|
809
|
+
1. Parsing: XAML を XML として読み、{Binding} 式を抽出
|
|
810
|
+
2. Analysis: Roslyn で ViewModel の型情報を取得し、Binding パスを検証
|
|
811
|
+
3. Reporting: 結果をコンソール / JSON で出力
|
|
812
|
+
|
|
813
|
+
## 命名規約
|
|
814
|
+
|
|
815
|
+
- View: `*Window.xaml`, `*View.xaml`, `*Page.xaml`
|
|
816
|
+
- ViewModel: `*ViewModel` (View 名 + "ViewModel")
|
|
817
|
+
```
|