jui-tools-mcp-server 2.7.0 → 2.10.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.
@@ -61,6 +61,10 @@ export interface DataSourceInfo {
61
61
  /// contract (cross-effect rulings, plan 33). Optional: absent on older
62
62
  /// jsonui-cli checkouts.
63
63
  attributeSemantics: FileInfo | null;
64
+ /// shared/core/platform_semantics.json — platform-scoped layout semantics
65
+ /// (the node-level 'platform' directive, root 'platforms', vs
66
+ /// 'responsive'). Optional: absent on older jsonui-cli checkouts.
67
+ platformSemantics: FileInfo | null;
64
68
  /// conformance/coverage.json — declared-but-unimplemented (component,
65
69
  /// attribute, platform) pairs with their recorded reasons. Optional: absent
66
70
  /// on older jsonui-cli checkouts.
@@ -102,6 +106,7 @@ export class SpecLoader {
102
106
  private commonAttributesRaw: Record<string, any> = {};
103
107
  private bindingSemantics: Record<string, any> | null = null;
104
108
  private attributeSemantics: Record<string, any> | null = null;
109
+ private platformSemantics: Record<string, any> | null = null;
105
110
  /// `${component}.${attribute}` -> [{platform, reason, note}] from the
106
111
  /// coverage ledger. The ledger tracks pairs that ARE declared (platform
107
112
  /// field includes them) but that the platform's codegen does not consume —
@@ -243,9 +248,56 @@ export class SpecLoader {
243
248
  }
244
249
  }
245
250
 
251
+ // Topic-guide index: task vocabulary ("iOSだけ色を変える", "ios only",
252
+ // "platform override") does not contain any component or attribute
253
+ // name, so the loops above can't surface the mechanism. Each guide
254
+ // declares its own keywords (en + ja) in its semantics file; a hit
255
+ // pins a pointer to the dedicated tool at the top of the results.
256
+ for (const guide of this.topicGuides()) {
257
+ const hit = guide.keywords.some(
258
+ (kw) => q.includes(kw) || (q.length >= 3 && kw.includes(q))
259
+ );
260
+ if (hit) {
261
+ results.push({
262
+ guide: guide.topic,
263
+ score: 100,
264
+ matches: [guide.summary],
265
+ nextTool: guide.tool,
266
+ });
267
+ }
268
+ }
269
+
246
270
  return results.sort((a, b) => b.score - a.score);
247
271
  }
248
272
 
273
+ /** Keyword-indexed topical guides, each backed by a dedicated MCP tool. */
274
+ private topicGuides(): Array<{
275
+ topic: string;
276
+ tool: string;
277
+ summary: string;
278
+ keywords: string[];
279
+ }> {
280
+ const guides: Array<{
281
+ topic: string;
282
+ tool: string;
283
+ summary: string;
284
+ keywords: string[];
285
+ }> = [];
286
+ if (this.platformSemantics) {
287
+ const kws = Array.isArray(this.platformSemantics.keywords)
288
+ ? (this.platformSemantics.keywords as string[])
289
+ : [];
290
+ guides.push({
291
+ topic: "platform_rules",
292
+ tool: "get_platform_rules",
293
+ summary:
294
+ "Per-platform styling/visibility (e.g. change fontColor on iOS only): node-level 'platform' directive (object form = per-platform overrides, string form = platform filter), root 'platforms' whitelist, vs 'responsive'. Call get_platform_rules for the full semantics with examples.",
295
+ keywords: kws.map((k) => k.toLowerCase()),
296
+ });
297
+ }
298
+ return guides;
299
+ }
300
+
249
301
  getModifierOrder(platform?: string): any {
250
302
  if (platform) return (MODIFIER_ORDER as any)[platform] || null;
251
303
  return MODIFIER_ORDER;
@@ -275,6 +327,7 @@ export class SpecLoader {
275
327
  this.dataSource.screenIdentity,
276
328
  this.dataSource.bindingSemantics,
277
329
  this.dataSource.attributeSemantics,
330
+ this.dataSource.platformSemantics,
278
331
  this.dataSource.coverage,
279
332
  ].filter((f): f is FileInfo => f != null);
280
333
 
@@ -309,6 +362,15 @@ export class SpecLoader {
309
362
  };
310
363
  }
311
364
 
365
+ getPlatformRules(): any {
366
+ // Canonical platform-scoping semantics (SSoT:
367
+ // shared/core/platform_semantics.json — restates
368
+ // jui_tools/jui_cli/core/platform_resolver.py). Null on jsonui-cli
369
+ // checkouts that predate the file; the tool reports that instead of
370
+ // inventing rules.
371
+ return this.platformSemantics;
372
+ }
373
+
312
374
  getPlatformMapping(category?: string): any {
313
375
  if (category) return (PLATFORM_MAPPING as any)[category] || null;
314
376
  return PLATFORM_MAPPING;
@@ -376,6 +438,23 @@ export class SpecLoader {
376
438
  this.attributeSemantics = null;
377
439
  }
378
440
 
441
+ // Platform-scoped layout semantics (node 'platform' directive, root
442
+ // 'platforms', vs 'responsive'). Optional for the same backward-compat
443
+ // reason.
444
+ let platformSemanticsResolution: FileInfo | null = null;
445
+ try {
446
+ const platSemResolution = this.resolveFile(
447
+ "shared/core/platform_semantics.json",
448
+ "data/platform_semantics.json"
449
+ );
450
+ this.platformSemantics = JSON.parse(
451
+ readFileSync(platSemResolution.path, "utf-8")
452
+ ) as Record<string, any>;
453
+ platformSemanticsResolution = platSemResolution;
454
+ } catch {
455
+ this.platformSemantics = null;
456
+ }
457
+
379
458
  // Canonical screen identity (screen-identity track). Optional so older
380
459
  // jsonui-cli checkouts without the file still load.
381
460
  try {
@@ -462,6 +541,7 @@ export class SpecLoader {
462
541
  screenIdentity: screenIdentityResolution,
463
542
  bindingSemantics: bindingResolution,
464
543
  attributeSemantics: attributeSemanticsResolution,
544
+ platformSemantics: platformSemanticsResolution,
465
545
  coverage: coverageResolution,
466
546
  componentCount,
467
547
  commonAttributeCount: Object.keys(this.commonAttributesRaw).filter(
@@ -22,20 +22,38 @@ export function register(server: McpServer, config: ServerConfig) {
22
22
  }
23
23
 
24
24
  const files = readdirSync(specDir).filter((f) => f.endsWith(".spec.json"));
25
- const specs = files.map((file) => {
25
+ const specs: any[] = [];
26
+ for (const file of files) {
26
27
  try {
27
28
  const data = JSON.parse(readFileSync(join(specDir, file), "utf-8"));
28
- return {
29
+ specs.push({
29
30
  file,
30
31
  name: data.metadata?.name || file.replace(".spec.json", ""),
31
32
  displayName: data.metadata?.displayName || "",
32
33
  type: data.type || "screen_spec",
33
34
  layoutFile: data.metadata?.layoutFile || null,
34
- };
35
+ });
36
+ // A parent spec splits one screen across sub-spec files (its
37
+ // 'subSpecs' array). Surface them inline so a session can see
38
+ // the split and read_spec_file the right part without first
39
+ // opening the parent.
40
+ if (Array.isArray(data.subSpecs)) {
41
+ for (const sub of data.subSpecs) {
42
+ if (!sub?.file) continue;
43
+ specs.push({
44
+ file: sub.file,
45
+ name: sub.name || sub.file,
46
+ displayName: sub.description || "",
47
+ type: "screen_sub_spec",
48
+ parent: file,
49
+ layoutFile: data.metadata?.layoutFile || null,
50
+ });
51
+ }
52
+ }
35
53
  } catch {
36
- return { file, name: file.replace(".spec.json", ""), displayName: "", type: "unknown", layoutFile: null };
54
+ specs.push({ file, name: file.replace(".spec.json", ""), displayName: "", type: "unknown", layoutFile: null });
37
55
  }
38
- });
56
+ }
39
57
 
40
58
  return { content: [{ type: "text", text: JSON.stringify(specs, null, 2) }] };
41
59
  } catch (e: any) {
@@ -0,0 +1,124 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { readFileSync, readdirSync, existsSync, statSync } from "fs";
4
+ import { join, relative } from "path";
5
+ import { ServerConfig } from "../../config.js";
6
+
7
+ const MAX_RESULTS = 30;
8
+ const SNIPPET_LEN = 200;
9
+
10
+ /** Recursively collect *.spec.json / *.component.json under dir. */
11
+ function collectSpecFiles(dir: string, out: string[] = []): string[] {
12
+ for (const entry of readdirSync(dir)) {
13
+ const p = join(dir, entry);
14
+ if (statSync(p).isDirectory()) {
15
+ collectSpecFiles(p, out);
16
+ } else if (entry.endsWith(".spec.json") || entry.endsWith(".component.json")) {
17
+ out.push(p);
18
+ }
19
+ }
20
+ return out;
21
+ }
22
+
23
+ /**
24
+ * Walk a parsed spec and report the JSON paths whose keys or string values
25
+ * contain the query. One entry per path, with a short snippet.
26
+ */
27
+ function findMatches(
28
+ node: any,
29
+ q: string,
30
+ path: string,
31
+ out: { path: string; snippet: string }[]
32
+ ) {
33
+ if (out.length >= MAX_RESULTS) return;
34
+ if (typeof node === "string") {
35
+ if (node.toLowerCase().includes(q)) {
36
+ out.push({ path, snippet: node.slice(0, SNIPPET_LEN) });
37
+ }
38
+ return;
39
+ }
40
+ if (Array.isArray(node)) {
41
+ node.forEach((v, i) => findMatches(v, q, `${path}[${i}]`, out));
42
+ return;
43
+ }
44
+ if (node && typeof node === "object") {
45
+ for (const [k, v] of Object.entries(node)) {
46
+ const childPath = path ? `${path}.${k}` : k;
47
+ if (k.toLowerCase().includes(q)) {
48
+ const snippet =
49
+ typeof v === "string"
50
+ ? v.slice(0, SNIPPET_LEN)
51
+ : JSON.stringify(v)?.slice(0, SNIPPET_LEN) ?? "";
52
+ out.push({ path: childPath, snippet });
53
+ if (out.length >= MAX_RESULTS) return;
54
+ }
55
+ findMatches(v, q, childPath, out);
56
+ if (out.length >= MAX_RESULTS) return;
57
+ }
58
+ }
59
+ }
60
+
61
+ export function register(server: McpServer, config: ServerConfig) {
62
+ server.tool(
63
+ "search_specs",
64
+ "Keyword-search across ALL project spec files (screen specs, sub-specs in subdirectories, component specs). Returns file + JSON path + snippet per match — the fast way to find which spec (or which sub-spec of a split screen) covers a topic",
65
+ {
66
+ query: z.string().describe("Search keyword (matched case-insensitively against keys and string values)"),
67
+ project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
68
+ },
69
+ async ({ query, project_dir }) => {
70
+ try {
71
+ const projectDir = config.resolveProjectDir(project_dir);
72
+ const projectConfig = config.readProjectConfig(projectDir);
73
+ const q = query.toLowerCase();
74
+ if (!q.trim()) {
75
+ return { content: [{ type: "text", text: "Empty query." }] };
76
+ }
77
+
78
+ const dirs = new Set<string>();
79
+ for (const field of ["spec_directory", "component_spec_directory"] as const) {
80
+ try {
81
+ const d = config.resolveDir(projectConfig, field, projectDir);
82
+ if (existsSync(d)) dirs.add(d);
83
+ } catch {
84
+ /* directory not configured */
85
+ }
86
+ }
87
+ if (dirs.size === 0) {
88
+ return { content: [{ type: "text", text: "No spec directories found." }] };
89
+ }
90
+
91
+ const results: any[] = [];
92
+ for (const dir of dirs) {
93
+ for (const filePath of collectSpecFiles(dir)) {
94
+ if (results.length >= MAX_RESULTS) break;
95
+ let matches: { path: string; snippet: string }[] = [];
96
+ try {
97
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
98
+ findMatches(data, q, "", matches);
99
+ } catch {
100
+ continue;
101
+ }
102
+ if (matches.length > 0) {
103
+ results.push({
104
+ file: relative(dir, filePath),
105
+ matchCount: matches.length,
106
+ matches: matches.slice(0, 5),
107
+ });
108
+ }
109
+ }
110
+ }
111
+
112
+ if (results.length === 0) {
113
+ return { content: [{ type: "text", text: `No matches for '${query}'.` }] };
114
+ }
115
+ results.sort((a, b) => b.matchCount - a.matchCount);
116
+ return {
117
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
118
+ };
119
+ } catch (e: any) {
120
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
121
+ }
122
+ }
123
+ );
124
+ }
@@ -7,7 +7,7 @@ import { runCli } from "../../cli_runner.js";
7
7
  export function register(server: McpServer, config: ServerConfig) {
8
8
  server.tool(
9
9
  "doc_validate_spec",
10
- "Validate a screen specification JSON file against the schema",
10
+ "Validate a screen specification JSON file against the schema. Also expands `@canonical` marks in dataFlow method params/returnType against the project's OpenAPI documents, so a mark that names no operation is reported here as an error rather than reaching `jui build`.",
11
11
  {
12
12
  file: z.string().describe("Spec file path (e.g., 'login.spec.json' or full path)"),
13
13
  project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
@@ -0,0 +1,26 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { SpecLoader } from "../../spec_loader.js";
3
+
4
+ export function register(server: McpServer, loader: SpecLoader) {
5
+ server.tool(
6
+ "get_platform_rules",
7
+ "Get platform-scoping rules for layouts: how to change an attribute on one platform only (node-level 'platform' object form, e.g. iOS-only fontColor), how to drop a node per platform (string form + language tokens), the layout-root 'platforms' whitelist, precedence, and how this differs from 'responsive'",
8
+ {},
9
+ async () => {
10
+ const result = loader.getPlatformRules();
11
+ if (!result) {
12
+ return {
13
+ content: [
14
+ {
15
+ type: "text",
16
+ text: "platform_semantics.json not found — the connected jsonui-cli checkout predates it. Fall back to lookup_attribute('platform') for the short definition.",
17
+ },
18
+ ],
19
+ };
20
+ }
21
+ return {
22
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
23
+ };
24
+ }
25
+ );
26
+ }
@@ -0,0 +1,72 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ServerConfig } from "../../config.js";
4
+ import { runCli, formatResult } from "../../cli_runner.js";
5
+
6
+ export function register(server: McpServer, config: ServerConfig) {
7
+ server.tool(
8
+ "test_generate_branch_tests",
9
+ "Generate unit-level branch tests from a screen spec's branchContracts " +
10
+ "(vitest / Kotlin JUnit4+Robolectric / Swift XCTest). Only the HTTP " +
11
+ "boundary is mocked — ViewModel, UseCase, Repository and response " +
12
+ "decoding all run for real. The generated test file and runtime are " +
13
+ "@generated; the harness skeleton is emitted once and then owned by " +
14
+ "the project. Screens with no branchContracts section are an error, " +
15
+ "not a no-op",
16
+ {
17
+ screen: z
18
+ .string()
19
+ .describe("Screen name in snake_case (resolves <spec_directory>/<screen>.spec.json)"),
20
+ platform: z
21
+ .enum(["web", "android", "ios"])
22
+ .optional()
23
+ .describe(
24
+ "Target platform (default web). android additionally requires " +
25
+ "`package`; ios additionally requires `module`"
26
+ ),
27
+ package: z
28
+ .string()
29
+ .optional()
30
+ .describe("Kotlin package for the generated sources — required for platform 'android'"),
31
+ module: z
32
+ .string()
33
+ .optional()
34
+ .describe("App module name for @testable import — required for platform 'ios'"),
35
+ spec: z
36
+ .string()
37
+ .optional()
38
+ .describe("Explicit spec file path (overrides spec_directory resolution)"),
39
+ out_dir: z
40
+ .string()
41
+ .optional()
42
+ .describe("Output directory for the @generated test + runtime files"),
43
+ harness_dir: z
44
+ .string()
45
+ .optional()
46
+ .describe("Project-owned harness directory (skeleton written only when absent)"),
47
+ mocks_dir: z
48
+ .string()
49
+ .optional()
50
+ .describe("Directory scanned for *.mock.json scenario files"),
51
+ project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
52
+ },
53
+ async (params) => {
54
+ try {
55
+ const projectDir = config.resolveProjectDir(params.project_dir);
56
+ const args = ["generate", "branch-tests", params.screen];
57
+ if (params.platform) { args.push("--platform", params.platform); }
58
+ if (params.package) { args.push("--package", params.package); }
59
+ if (params.module) { args.push("--module", params.module); }
60
+ if (params.spec) { args.push("--spec", params.spec); }
61
+ if (params.out_dir) { args.push("--out-dir", params.out_dir); }
62
+ if (params.harness_dir) { args.push("--harness-dir", params.harness_dir); }
63
+ if (params.mocks_dir) { args.push("--mocks-dir", params.mocks_dir); }
64
+
65
+ const result = await runCli("jsonui-test", args, { cwd: projectDir });
66
+ return { content: [{ type: "text", text: formatResult(result) }] };
67
+ } catch (e: any) {
68
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
69
+ }
70
+ }
71
+ );
72
+ }
@@ -45,6 +45,7 @@ function reportedFiles(source: DataSourceInfo) {
45
45
  source.screenIdentity,
46
46
  source.bindingSemantics,
47
47
  source.attributeSemantics,
48
+ source.platformSemantics,
48
49
  source.coverage,
49
50
  ].filter((f): f is NonNullable<typeof f> => f != null);
50
51
  }
@@ -386,6 +386,61 @@ describe("SpecLoader spec model", () => {
386
386
  });
387
387
  });
388
388
 
389
+ describe("SpecLoader platform rules + topic-guide search", () => {
390
+ const SEMANTICS = {
391
+ version: 1,
392
+ title: "Platform-scoped layout semantics",
393
+ keywords: ["platform", "ios only", "iosだけ", "出し分け"],
394
+ nodeDirective: { attribute: "platform" },
395
+ };
396
+
397
+ function loaderWithSemantics(): SpecLoader {
398
+ const mcpRoot = makeTempDir("mcp-root");
399
+ makeBundledDataset(mcpRoot);
400
+ writeJson(join(mcpRoot, "data/platform_semantics.json"), SEMANTICS);
401
+ return new SpecLoader(mcpRoot);
402
+ }
403
+
404
+ it("getPlatformRules returns the semantics file verbatim", () => {
405
+ expect(loaderWithSemantics().getPlatformRules()).toEqual(SEMANTICS);
406
+ });
407
+
408
+ it("getPlatformRules is null (not invented) without the file", () => {
409
+ const mcpRoot = makeTempDir("mcp-root");
410
+ makeBundledDataset(mcpRoot);
411
+ expect(new SpecLoader(mcpRoot).getPlatformRules()).toBeNull();
412
+ });
413
+
414
+ it("task vocabulary surfaces the guide pointer at the top of search", () => {
415
+ // No component or attribute name in the query — only the guide can hit.
416
+ const results = loaderWithSemantics().searchComponents(
417
+ "iosだけフォントカラーを変えたい"
418
+ );
419
+ expect(results[0]).toMatchObject({
420
+ guide: "platform_rules",
421
+ nextTool: "get_platform_rules",
422
+ score: 100,
423
+ });
424
+ });
425
+
426
+ it("an english task query hits the guide too", () => {
427
+ const results = loaderWithSemantics().searchComponents("ios only color");
428
+ expect(results.some((r: any) => r.guide === "platform_rules")).toBe(true);
429
+ });
430
+
431
+ it("guide keywords do not fire on unrelated queries", () => {
432
+ const results = loaderWithSemantics().searchComponents("zebra");
433
+ expect(results.every((r: any) => r.guide == null)).toBe(true);
434
+ });
435
+
436
+ it("no guide entries without the semantics file", () => {
437
+ const mcpRoot = makeTempDir("mcp-root");
438
+ makeBundledDataset(mcpRoot);
439
+ const results = new SpecLoader(mcpRoot).searchComponents("ios only");
440
+ expect(results.every((r: any) => r.guide == null)).toBe(true);
441
+ });
442
+ });
443
+
389
444
  // ----- root helper ----------------------------------------------------------
390
445
 
391
446
  describe("mcpRootFromImportMetaUrl", () => {
@@ -13,6 +13,7 @@ import { register as registerListComponentSpecs } from "../src/tools/context/lis
13
13
  import { register as registerListLayouts } from "../src/tools/context/list_layouts.js";
14
14
  import { register as registerReadSpecFile } from "../src/tools/context/read_spec_file.js";
15
15
  import { register as registerReadLayoutFile } from "../src/tools/context/read_layout_file.js";
16
+ import { register as registerSearchSpecs } from "../src/tools/context/search_specs.js";
16
17
  import {
17
18
  cleanupTempDirs,
18
19
  createToolHarness,
@@ -38,8 +39,46 @@ beforeEach(() => {
38
39
  registerListLayouts(harness.server, config);
39
40
  registerReadSpecFile(harness.server, config);
40
41
  registerReadLayoutFile(harness.server, config);
42
+ registerSearchSpecs(harness.server, config);
41
43
  });
42
44
 
45
+ /** A screen split across a parent spec + sub-spec files in a subdirectory. */
46
+ function writeSplitSpecFixture(root: string) {
47
+ writeJson(join(root, "docs/screens/json/messages.spec.json"), {
48
+ type: "screen_parent_spec",
49
+ metadata: {
50
+ name: "Messages",
51
+ displayName: "Messages Screen",
52
+ layoutFile: "messages",
53
+ },
54
+ subSpecs: [
55
+ {
56
+ file: "messages/messages-core.spec.json",
57
+ name: "Messages - Core",
58
+ description: "Core structure and message list",
59
+ },
60
+ {
61
+ file: "messages/messages-composer.spec.json",
62
+ name: "Messages - Composer",
63
+ description: "Input area and attachments",
64
+ },
65
+ ],
66
+ });
67
+ writeJson(join(root, "docs/screens/json/messages/messages-core.spec.json"), {
68
+ type: "screen_spec",
69
+ metadata: { name: "Messages - Core" },
70
+ structure: { messageList: { paging: true, currentPage: "@{currentPage}" } },
71
+ });
72
+ writeJson(
73
+ join(root, "docs/screens/json/messages/messages-composer.spec.json"),
74
+ {
75
+ type: "screen_spec",
76
+ metadata: { name: "Messages - Composer" },
77
+ structure: { attachButton: { note: "opens the attachment sheet" } },
78
+ }
79
+ );
80
+ }
81
+
43
82
  afterEach(() => {
44
83
  delete process.env.JUI_PROJECT_DIR;
45
84
  cleanupTempDirs();
@@ -121,6 +160,81 @@ describe("list_screen_specs", () => {
121
160
  const text = await harness.call("list_screen_specs", { project_dir: proj });
122
161
  expect(text).toContain("Spec directory not found");
123
162
  });
163
+
164
+ it("expands a parent spec's subSpecs inline so the split is visible", async () => {
165
+ writeSplitSpecFixture(projectDir);
166
+ const specs = JSON.parse(
167
+ await harness.call("list_screen_specs", { project_dir: projectDir })
168
+ );
169
+ const core = specs.find(
170
+ (s: any) => s.file === "messages/messages-core.spec.json"
171
+ );
172
+ expect(core).toEqual({
173
+ file: "messages/messages-core.spec.json",
174
+ name: "Messages - Core",
175
+ displayName: "Core structure and message list",
176
+ type: "screen_sub_spec",
177
+ parent: "messages.spec.json",
178
+ layoutFile: "messages",
179
+ });
180
+ // The parent itself still lists normally.
181
+ expect(
182
+ specs.find((s: any) => s.file === "messages.spec.json")?.type
183
+ ).toBe("screen_parent_spec");
184
+ });
185
+ });
186
+
187
+ describe("search_specs", () => {
188
+ it("finds a keyword inside a sub-spec file in a subdirectory", async () => {
189
+ writeSplitSpecFixture(projectDir);
190
+ const results = JSON.parse(
191
+ await harness.call("search_specs", {
192
+ query: "currentPage",
193
+ project_dir: projectDir,
194
+ })
195
+ );
196
+ const hit = results.find(
197
+ (r: any) => r.file === "messages/messages-core.spec.json"
198
+ );
199
+ expect(hit).toBeTruthy();
200
+ expect(hit.matches[0].path).toContain("structure.messageList");
201
+ expect(hit.matches[0].snippet).toContain("@{currentPage}");
202
+ });
203
+
204
+ it("matches keys as well as string values, case-insensitively", async () => {
205
+ writeSplitSpecFixture(projectDir);
206
+ const results = JSON.parse(
207
+ await harness.call("search_specs", {
208
+ query: "ATTACHMENT",
209
+ project_dir: projectDir,
210
+ })
211
+ );
212
+ expect(
213
+ results.some(
214
+ (r: any) => r.file === "messages/messages-composer.spec.json"
215
+ )
216
+ ).toBe(true);
217
+ });
218
+
219
+ it("searches component specs too", async () => {
220
+ const results = JSON.parse(
221
+ await harness.call("search_specs", {
222
+ query: "ExampleCard",
223
+ project_dir: projectDir,
224
+ })
225
+ );
226
+ expect(
227
+ results.some((r: any) => r.file === "example_card.component.json")
228
+ ).toBe(true);
229
+ });
230
+
231
+ it("reports no matches as a plain message", async () => {
232
+ const text = await harness.call("search_specs", {
233
+ query: "zzz_nothing_zzz",
234
+ project_dir: projectDir,
235
+ });
236
+ expect(text).toContain("No matches");
237
+ });
124
238
  });
125
239
 
126
240
  describe("list_component_specs", () => {