jui-tools-mcp-server 2.7.0 → 2.8.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.
@@ -0,0 +1,70 @@
1
+ {
2
+ "version": 1,
3
+ "title": "Platform-scoped layout semantics",
4
+ "purpose": "How to make a node render differently (or not at all) on one platform: the node-level 'platform' directive, the layout-root 'platforms' whitelist, and how they differ from 'responsive'. Canonical implementation: jui_tools/jui_cli/core/platform_resolver.py — this file restates it for lookup and must follow it, never lead it.",
5
+ "keywords": [
6
+ "platform", "platforms", "ios only", "android only", "web only",
7
+ "per-platform", "override", "platform-specific", "platform limited",
8
+ "プラットフォーム", "限定", "出し分け", "iosだけ", "androidだけ", "webだけ",
9
+ "プラットフォーム別", "機種別"
10
+ ],
11
+ "nodeDirective": {
12
+ "attribute": "platform",
13
+ "consumedAt": "distribution (jui build). The key never appears in distributed per-platform output; platform-specific emit is produced from it.",
14
+ "objectForm": {
15
+ "summary": "Per-platform attribute overrides, merged into the node for the matching platform only. Override wins over the base value; merge is per top-level key (the override value replaces the base value for that key wholesale — it is not deep-merged into it).",
16
+ "example": {
17
+ "prompt": "Change fontColor only on iOS",
18
+ "layout": {
19
+ "type": "Label",
20
+ "text": "@{title}",
21
+ "fontColor": "#333333",
22
+ "platform": { "ios": { "fontColor": "#FFD700" } }
23
+ },
24
+ "resolved": {
25
+ "ios": { "type": "Label", "text": "@{title}", "fontColor": "#FFD700" },
26
+ "android": { "type": "Label", "text": "@{title}", "fontColor": "#333333" },
27
+ "web": { "type": "Label", "text": "@{title}", "fontColor": "#333333" }
28
+ }
29
+ },
30
+ "multiPlatformExample": {
31
+ "prompt": "Different heights everywhere, plus a web-only extra attribute",
32
+ "layout": {
33
+ "type": "View",
34
+ "height": 200,
35
+ "platform": {
36
+ "ios": { "height": 220 },
37
+ "android": { "height": 180 },
38
+ "web": { "height": "100vh", "maxWidth": 1200 }
39
+ }
40
+ }
41
+ },
42
+ "detection": "A dict counts as an override map ONLY when every key is one of 'ios' / 'android' / 'web'. Any other key (e.g. a stray 'class') makes the whole value a non-override, so nothing merges — keep override maps pure.",
43
+ "nesting": "Override values are themselves resolved recursively, so an override may contain child nodes with their own 'platform' directives."
44
+ },
45
+ "stringForm": {
46
+ "summary": "Node filter: the node (and its whole subtree) is DROPPED on platforms that do not match. Comma-separated tokens allowed.",
47
+ "example": {
48
+ "prompt": "Render this node on Android only",
49
+ "layout": { "type": "View", "platform": "android", "child": [] }
50
+ },
51
+ "tokens": {
52
+ "ios": ["ios", "swift", "swiftui", "uikit"],
53
+ "android": ["android", "kotlin", "java", "compose", "xml"],
54
+ "web": ["web", "typescript", "javascript", "react"]
55
+ }
56
+ }
57
+ },
58
+ "layoutRootPlatforms": {
59
+ "attribute": "platforms",
60
+ "summary": "Layout-root array whitelisting the distribution targets of the ENTIRE file (e.g. [\"ios\", \"android\"]). Distinct from the node-level 'platform' directive: 'platforms' picks which platforms get the file at all; 'platform' shapes a single node per platform."
61
+ },
62
+ "vsResponsive": {
63
+ "summary": "'responsive' is resolved at RUNTIME by each platform's framework (window size class / orientation); 'platform' is resolved at BUILD time and is invisible at runtime. Use 'platform' for per-OS differences, 'responsive' for per-form-factor differences. The resolver leaves 'responsive' untouched."
64
+ },
65
+ "authoringGuidance": {
66
+ "spec": "Screen specs stay platform-agnostic; when a node intentionally differs per platform, note the difference in the spec text of the section that owns the node (do not fork the spec per platform). Large screens split into sub-specs list them in the parent's 'subSpecs' array — the sub-spec that owns the node carries the note.",
67
+ "precedence": "Per-node 'platform' override > inline base attribute > 'style'-provided value. Distribution bakes the override into the node as a plain inline attribute; style merge (applied later, at codegen or dynamic runtime) always yields to inline attributes.",
68
+ "dynamicFace": "Distribution has already resolved 'platform' by the time a layout reaches a device, so the dynamic face never sees the key either — declaring it costs nothing at runtime on any face."
69
+ }
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "MCP server for JsonUI project management - wraps jui and jsonui-doc CLIs plus component spec lookup",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -33,6 +33,13 @@ export const FILES = [
33
33
  remote: "shared/core/attribute_semantics.json",
34
34
  local: "attribute_semantics.json",
35
35
  },
36
+ // Canonical platform-scoping semantics (node 'platform' directive, root
37
+ // 'platforms', vs 'responsive') — backs get_platform_rules and the
38
+ // search-side topic-guide index.
39
+ {
40
+ remote: "shared/core/platform_semantics.json",
41
+ local: "platform_semantics.json",
42
+ },
36
43
  // Canonical binding-resolution semantics. This was bundled in data/ but
37
44
  // missing from FILES, so the snapshot only ever refreshed by hand and
38
45
  // silently went stale — the same trap the screen-identity asset would
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { register as registerGetBindingRules } from "./tools/spec/get_binding_ru
14
14
  import { register as registerGetScreenIdentity } from "./tools/spec/get_screen_identity.js";
15
15
  import { register as registerGetPlatformMapping } from "./tools/spec/get_platform_mapping.js";
16
16
  import { register as registerGetDataSource } from "./tools/spec/get_data_source.js";
17
+ import { register as registerGetPlatformRules } from "./tools/spec/get_platform_rules.js";
17
18
 
18
19
  // --- Group B: Project Context ---
19
20
  import { register as registerGetProjectConfig } from "./tools/context/get_project_config.js";
@@ -22,6 +23,7 @@ import { register as registerListComponentSpecs } from "./tools/context/list_com
22
23
  import { register as registerListLayouts } from "./tools/context/list_layouts.js";
23
24
  import { register as registerReadSpecFile } from "./tools/context/read_spec_file.js";
24
25
  import { register as registerReadLayoutFile } from "./tools/context/read_layout_file.js";
26
+ import { register as registerSearchSpecs } from "./tools/context/search_specs.js";
25
27
 
26
28
  // --- Group C: jui CLI ---
27
29
  import { register as registerJuiInit } from "./tools/jui/jui_init.js";
@@ -89,7 +91,7 @@ log(
89
91
  // Initialize server config for Groups B/C/D
90
92
  const config = new ServerConfig();
91
93
 
92
- // Register Group A: Component Spec Lookup (7 tools)
94
+ // Register Group A: Component Spec Lookup (9 tools)
93
95
  registerLookupComponent(server, loader);
94
96
  registerLookupAttribute(server, loader);
95
97
  registerSearchComponents(server, loader);
@@ -98,14 +100,16 @@ registerGetBindingRules(server, loader);
98
100
  registerGetScreenIdentity(server, loader);
99
101
  registerGetPlatformMapping(server, loader);
100
102
  registerGetDataSource(server, loader);
103
+ registerGetPlatformRules(server, loader);
101
104
 
102
- // Register Group B: Project Context (6 tools)
105
+ // Register Group B: Project Context (7 tools)
103
106
  registerGetProjectConfig(server, config);
104
107
  registerListScreenSpecs(server, config);
105
108
  registerListComponentSpecs(server, config);
106
109
  registerListLayouts(server, config);
107
110
  registerReadSpecFile(server, config);
108
111
  registerReadLayoutFile(server, config);
112
+ registerSearchSpecs(server, config);
109
113
 
110
114
  // Register Group C: jui CLI (8 tools)
111
115
  registerJuiInit(server, config);
@@ -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
+ }
@@ -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
+ }
@@ -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", () => {