jui-tools-mcp-server 2.5.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 (172) hide show
  1. package/.github/workflows/test.yml +35 -0
  2. package/README.md +148 -0
  3. package/data/attribute_definitions.json +4248 -0
  4. package/data/attribute_semantics.json +137 -0
  5. package/data/binding_semantics.json +115 -0
  6. package/data/component_metadata.json +769 -0
  7. package/data/coverage.json +114 -0
  8. package/data/screen_identity.json +350 -0
  9. package/dist/cli_runner.d.ts +10 -0
  10. package/dist/cli_runner.js +33 -0
  11. package/dist/config.d.ts +19 -0
  12. package/dist/config.js +67 -0
  13. package/dist/data/component_metadata.d.ts +30 -0
  14. package/dist/data/component_metadata.js +498 -0
  15. package/dist/data/derived.d.ts +198 -0
  16. package/dist/data/derived.js +293 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +130 -0
  19. package/dist/spec_loader.d.ts +110 -0
  20. package/dist/spec_loader.js +445 -0
  21. package/dist/tools/api/list_api_models.d.ts +10 -0
  22. package/dist/tools/api/list_api_models.js +37 -0
  23. package/dist/tools/api/list_api_specs.d.ts +19 -0
  24. package/dist/tools/api/list_api_specs.js +42 -0
  25. package/dist/tools/api/preview_api_model_sync.d.ts +11 -0
  26. package/dist/tools/api/preview_api_model_sync.js +34 -0
  27. package/dist/tools/context/get_project_config.d.ts +3 -0
  28. package/dist/tools/context/get_project_config.js +20 -0
  29. package/dist/tools/context/list_component_specs.d.ts +3 -0
  30. package/dist/tools/context/list_component_specs.js +35 -0
  31. package/dist/tools/context/list_layouts.d.ts +3 -0
  32. package/dist/tools/context/list_layouts.js +118 -0
  33. package/dist/tools/context/list_screen_specs.d.ts +3 -0
  34. package/dist/tools/context/list_screen_specs.js +37 -0
  35. package/dist/tools/context/read_layout_file.d.ts +3 -0
  36. package/dist/tools/context/read_layout_file.js +25 -0
  37. package/dist/tools/context/read_spec_file.d.ts +3 -0
  38. package/dist/tools/context/read_spec_file.js +27 -0
  39. package/dist/tools/doc/doc_generate_component.d.ts +3 -0
  40. package/dist/tools/doc/doc_generate_component.js +38 -0
  41. package/dist/tools/doc/doc_generate_html.d.ts +3 -0
  42. package/dist/tools/doc/doc_generate_html.js +46 -0
  43. package/dist/tools/doc/doc_generate_spec.d.ts +3 -0
  44. package/dist/tools/doc/doc_generate_spec.js +38 -0
  45. package/dist/tools/doc/doc_init_component.d.ts +3 -0
  46. package/dist/tools/doc/doc_init_component.js +30 -0
  47. package/dist/tools/doc/doc_init_spec.d.ts +3 -0
  48. package/dist/tools/doc/doc_init_spec.js +33 -0
  49. package/dist/tools/doc/doc_rules_init.d.ts +3 -0
  50. package/dist/tools/doc/doc_rules_init.js +25 -0
  51. package/dist/tools/doc/doc_rules_show.d.ts +3 -0
  52. package/dist/tools/doc/doc_rules_show.js +21 -0
  53. package/dist/tools/doc/doc_validate_component.d.ts +3 -0
  54. package/dist/tools/doc/doc_validate_component.js +39 -0
  55. package/dist/tools/doc/doc_validate_spec.d.ts +3 -0
  56. package/dist/tools/doc/doc_validate_spec.js +40 -0
  57. package/dist/tools/jui/jui_build.d.ts +3 -0
  58. package/dist/tools/jui/jui_build.js +31 -0
  59. package/dist/tools/jui/jui_generate_converter.d.ts +3 -0
  60. package/dist/tools/jui/jui_generate_converter.js +43 -0
  61. package/dist/tools/jui/jui_generate_project.d.ts +3 -0
  62. package/dist/tools/jui/jui_generate_project.js +43 -0
  63. package/dist/tools/jui/jui_generate_screen.d.ts +3 -0
  64. package/dist/tools/jui/jui_generate_screen.js +22 -0
  65. package/dist/tools/jui/jui_init.d.ts +3 -0
  66. package/dist/tools/jui/jui_init.js +42 -0
  67. package/dist/tools/jui/jui_migrate_layouts.d.ts +3 -0
  68. package/dist/tools/jui/jui_migrate_layouts.js +25 -0
  69. package/dist/tools/jui/jui_sync_tool.d.ts +3 -0
  70. package/dist/tools/jui/jui_sync_tool.js +33 -0
  71. package/dist/tools/jui/jui_verify.d.ts +3 -0
  72. package/dist/tools/jui/jui_verify.js +43 -0
  73. package/dist/tools/spec/get_binding_rules.d.ts +3 -0
  74. package/dist/tools/spec/get_binding_rules.js +8 -0
  75. package/dist/tools/spec/get_data_source.d.ts +3 -0
  76. package/dist/tools/spec/get_data_source.js +41 -0
  77. package/dist/tools/spec/get_modifier_order.d.ts +3 -0
  78. package/dist/tools/spec/get_modifier_order.js +14 -0
  79. package/dist/tools/spec/get_platform_mapping.d.ts +3 -0
  80. package/dist/tools/spec/get_platform_mapping.js +20 -0
  81. package/dist/tools/spec/get_screen_identity.d.ts +3 -0
  82. package/dist/tools/spec/get_screen_identity.js +22 -0
  83. package/dist/tools/spec/lookup_attribute.d.ts +3 -0
  84. package/dist/tools/spec/lookup_attribute.js +14 -0
  85. package/dist/tools/spec/lookup_component.d.ts +3 -0
  86. package/dist/tools/spec/lookup_component.js +15 -0
  87. package/dist/tools/spec/search_components.d.ts +3 -0
  88. package/dist/tools/spec/search_components.js +14 -0
  89. package/dist/tools/test/test_artifacts_pull.d.ts +3 -0
  90. package/dist/tools/test/test_artifacts_pull.js +48 -0
  91. package/dist/tools/test/test_artifacts_status.d.ts +3 -0
  92. package/dist/tools/test/test_artifacts_status.js +28 -0
  93. package/dist/tools/test/test_generate_description.d.ts +3 -0
  94. package/dist/tools/test/test_generate_description.js +27 -0
  95. package/dist/tools/test/test_generate_flow.d.ts +3 -0
  96. package/dist/tools/test/test_generate_flow.js +29 -0
  97. package/dist/tools/test/test_generate_screen.d.ts +3 -0
  98. package/dist/tools/test/test_generate_screen.js +29 -0
  99. package/dist/tools/test/test_mock_generate.d.ts +3 -0
  100. package/dist/tools/test/test_mock_generate.js +64 -0
  101. package/dist/tools/test/test_report.d.ts +3 -0
  102. package/dist/tools/test/test_report.js +26 -0
  103. package/dist/tools/test/test_validate.d.ts +3 -0
  104. package/dist/tools/test/test_validate.js +43 -0
  105. package/docs/design.md +227 -0
  106. package/install.sh +90 -0
  107. package/package.json +24 -0
  108. package/scripts/fetch-definitions.js +101 -0
  109. package/scripts/fetch-manifest.js +60 -0
  110. package/src/cli_runner.ts +55 -0
  111. package/src/config.ts +88 -0
  112. package/src/data/derived.ts +305 -0
  113. package/src/index.ts +157 -0
  114. package/src/spec_loader.ts +591 -0
  115. package/src/tools/api/list_api_models.ts +44 -0
  116. package/src/tools/api/list_api_specs.ts +53 -0
  117. package/src/tools/api/preview_api_model_sync.ts +41 -0
  118. package/src/tools/context/get_project_config.ts +27 -0
  119. package/src/tools/context/list_component_specs.ts +44 -0
  120. package/src/tools/context/list_layouts.ts +131 -0
  121. package/src/tools/context/list_screen_specs.ts +46 -0
  122. package/src/tools/context/read_layout_file.ts +35 -0
  123. package/src/tools/context/read_spec_file.ts +38 -0
  124. package/src/tools/doc/doc_generate_component.ts +43 -0
  125. package/src/tools/doc/doc_generate_html.ts +40 -0
  126. package/src/tools/doc/doc_generate_spec.ts +43 -0
  127. package/src/tools/doc/doc_init_component.ts +32 -0
  128. package/src/tools/doc/doc_init_spec.ts +37 -0
  129. package/src/tools/doc/doc_rules_init.ts +29 -0
  130. package/src/tools/doc/doc_rules_show.ts +27 -0
  131. package/src/tools/doc/doc_validate_component.ts +47 -0
  132. package/src/tools/doc/doc_validate_spec.ts +48 -0
  133. package/src/tools/jui/jui_build.ts +31 -0
  134. package/src/tools/jui/jui_generate_converter.ts +41 -0
  135. package/src/tools/jui/jui_generate_project.ts +37 -0
  136. package/src/tools/jui/jui_generate_screen.ts +28 -0
  137. package/src/tools/jui/jui_init.ts +38 -0
  138. package/src/tools/jui/jui_migrate_layouts.ts +29 -0
  139. package/src/tools/jui/jui_sync_tool.ts +33 -0
  140. package/src/tools/jui/jui_verify.ts +43 -0
  141. package/src/tools/spec/get_binding_rules.ts +16 -0
  142. package/src/tools/spec/get_data_source.ts +53 -0
  143. package/src/tools/spec/get_modifier_order.ts +22 -0
  144. package/src/tools/spec/get_platform_mapping.ts +28 -0
  145. package/src/tools/spec/get_screen_identity.ts +31 -0
  146. package/src/tools/spec/lookup_attribute.ts +22 -0
  147. package/src/tools/spec/lookup_component.ts +23 -0
  148. package/src/tools/spec/search_components.ts +22 -0
  149. package/src/tools/test/test_artifacts_pull.ts +47 -0
  150. package/src/tools/test/test_artifacts_status.ts +34 -0
  151. package/src/tools/test/test_generate_description.ts +33 -0
  152. package/src/tools/test/test_generate_flow.ts +33 -0
  153. package/src/tools/test/test_generate_screen.ts +33 -0
  154. package/src/tools/test/test_mock_generate.ts +58 -0
  155. package/src/tools/test/test_report.ts +32 -0
  156. package/src/tools/test/test_validate.ts +43 -0
  157. package/tests/__snapshots__/derived.test.ts.snap +279 -0
  158. package/tests/bundled_snapshot.test.ts +155 -0
  159. package/tests/cli_runner.test.ts +120 -0
  160. package/tests/config.test.ts +212 -0
  161. package/tests/derived.test.ts +114 -0
  162. package/tests/helpers.ts +272 -0
  163. package/tests/setup.ts +27 -0
  164. package/tests/snapshot_completeness.test.ts +152 -0
  165. package/tests/spec_loader.test.ts +488 -0
  166. package/tests/tools_cli.test.ts +687 -0
  167. package/tests/tools_context.test.ts +274 -0
  168. package/tests/tools_spec.test.ts +225 -0
  169. package/tests/tools_test.test.ts +284 -0
  170. package/tsconfig.json +15 -0
  171. package/uninstall.sh +46 -0
  172. package/vitest.config.ts +15 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Bundled snapshot validity — loads the SpecLoader against the REAL committed
3
+ * data/ snapshot (attribute_definitions.json + component_metadata.json) by
4
+ * forcing the 4th fallback layer. Guards against a broken snapshot refresh:
5
+ * if `data/` ever ships in a state the server can't load or that breaks core
6
+ * lookups, these tests fail.
7
+ *
8
+ * The env/cwd/home layers are neutralized so the test only ever reads the
9
+ * files committed in this repo.
10
+ */
11
+ import { fileURLToPath } from "url";
12
+ import { dirname, join, resolve } from "path";
13
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
14
+
15
+ import { SpecLoader } from "../src/spec_loader.js";
16
+ import { cleanupTempDirs, makeTempDir } from "./helpers.js";
17
+
18
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
19
+ const originalCwd = process.cwd();
20
+ let loader: SpecLoader;
21
+
22
+ beforeAll(() => {
23
+ delete process.env.JSONUI_CLI_PATH;
24
+ const home = makeTempDir("home");
25
+ process.env.HOME = home;
26
+ process.env.USERPROFILE = home;
27
+ process.chdir(makeTempDir("cwd"));
28
+ loader = new SpecLoader(repoRoot);
29
+ });
30
+
31
+ afterAll(() => {
32
+ process.chdir(originalCwd);
33
+ cleanupTempDirs();
34
+ });
35
+
36
+ describe("bundled data/ snapshot", () => {
37
+ it("loads via the bundled layer from this repo's data/ directory", () => {
38
+ const source = loader.getDataSource();
39
+ expect(source.attributeDefinitions.layer).toBe("bundled");
40
+ expect(source.attributeDefinitions.path).toBe(
41
+ join(repoRoot, "data", "attribute_definitions.json")
42
+ );
43
+ expect(source.componentMetadata.layer).toBe("bundled");
44
+ });
45
+
46
+ it("contains a sane component and common-attribute population", () => {
47
+ const source = loader.getDataSource();
48
+ expect(source.componentCount).toBeGreaterThanOrEqual(25);
49
+ expect(source.commonAttributeCount).toBeGreaterThanOrEqual(100);
50
+ });
51
+
52
+ it("includes every core component", () => {
53
+ const components = loader.listComponents();
54
+ for (const name of [
55
+ "View",
56
+ "Label",
57
+ "TextField",
58
+ "Button",
59
+ "Image",
60
+ "NetworkImage",
61
+ "ScrollView",
62
+ "Collection",
63
+ "SelectBox",
64
+ "Switch",
65
+ // Toggle is a component alias of Switch (`_alias_of`) — resolved,
66
+ // not listed; see "resolves the canonical component aliases" below.
67
+ "Segment",
68
+ "Slider",
69
+ "Progress",
70
+ "Radio",
71
+ "CheckBox",
72
+ "Indicator",
73
+ "SafeAreaView",
74
+ "TabView",
75
+ "Web",
76
+ "Embed",
77
+ ]) {
78
+ expect(components, `missing component: ${name}`).toContain(name);
79
+ }
80
+ });
81
+
82
+ it("serves the $jui meta-attribute from the common section", () => {
83
+ const attr = loader.getAttribute("$jui");
84
+ expect(attr).not.toBeNull();
85
+ expect(attr.scope).toBe("common");
86
+ expect(attr.definition.type).toBe("object");
87
+ expect(attr.definition.description).toContain("normalize");
88
+ });
89
+
90
+ it("resolves the canonical component aliases", () => {
91
+ expect(loader.getComponent("Text")?.name).toBe("Label");
92
+ expect(loader.getComponent("EditText")?.name).toBe("TextField");
93
+ expect(loader.getComponent("Input")?.name).toBe("TextField");
94
+ expect(loader.getComponent("Check")?.name).toBe("CheckBox");
95
+ // Not in any metadata `aliases` array — resolved from the section's
96
+ // `_alias_of` pointer alone (B1).
97
+ expect(loader.getComponent("Toggle")?.name).toBe("Switch");
98
+ });
99
+
100
+ it("derives two-way binding for TextField.text and read-only for Label.text", () => {
101
+ expect(loader.getComponent("TextField")!.bindingBehavior.text).toEqual({
102
+ direction: "two-way",
103
+ });
104
+ expect(loader.getComponent("Label")!.bindingBehavior.text).toEqual({
105
+ direction: "read-only",
106
+ });
107
+ });
108
+
109
+ it("gives every component a well-formed platform matrix and description", () => {
110
+ for (const name of loader.listComponents()) {
111
+ const comp = loader.getComponent(name)!;
112
+ expect(Object.keys(comp.platforms).sort()).toEqual([
113
+ "kotlin_dynamic",
114
+ "kotlin_generated",
115
+ "react",
116
+ "swift_dynamic",
117
+ "swift_generated",
118
+ ]);
119
+ for (const value of Object.values(comp.platforms)) {
120
+ expect(typeof value).toBe("boolean");
121
+ }
122
+ expect(comp.description.length).toBeGreaterThan(0);
123
+ expect(Array.isArray(comp.aliases)).toBe(true);
124
+ expect(Array.isArray(comp.rules)).toBe(true);
125
+ }
126
+ });
127
+
128
+ it("only emits valid binding directions", () => {
129
+ for (const name of loader.listComponents()) {
130
+ const comp = loader.getComponent(name)!;
131
+ for (const behavior of Object.values(comp.bindingBehavior)) {
132
+ expect(["two-way", "read-only"]).toContain(
133
+ (behavior as { direction: string }).direction
134
+ );
135
+ }
136
+ }
137
+ });
138
+
139
+ it("categorizes common attributes without losing any", () => {
140
+ const withCommon = loader.getComponentWithCommon("View");
141
+ const categories = withCommon.commonAttributes.categories;
142
+ let total = 0;
143
+ for (const attrs of Object.values(categories)) {
144
+ total += Object.keys(attrs as Record<string, unknown>).length;
145
+ }
146
+ expect(total).toBe(loader.getDataSource().commonAttributeCount);
147
+ });
148
+
149
+ it("answers core attribute lookups", () => {
150
+ expect(loader.getAttribute("width")).not.toBeNull();
151
+ expect(loader.getAttribute("background")).not.toBeNull();
152
+ expect(loader.getAttribute("onclick")).not.toBeNull();
153
+ expect(loader.searchComponents("binding").length).toBeGreaterThan(0);
154
+ });
155
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * cli_runner — execFile wrapper. child_process is mocked; no real CLI runs.
3
+ */
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const execFileMock = vi.hoisted(() => vi.fn());
7
+ vi.mock("child_process", () => ({ execFile: execFileMock }));
8
+ vi.mock("node:child_process", () => ({ execFile: execFileMock }));
9
+
10
+ import { runCli, formatResult, type CliResult } from "../src/cli_runner.js";
11
+
12
+ type ExecCallback = (error: any, stdout: string, stderr: string) => void;
13
+
14
+ interface RecordedCall {
15
+ command: string;
16
+ args: string[];
17
+ options: Record<string, any>;
18
+ }
19
+
20
+ let recorded: RecordedCall[];
21
+ let nextResponse: { error?: any; stdout?: string; stderr?: string };
22
+
23
+ beforeEach(() => {
24
+ recorded = [];
25
+ nextResponse = {};
26
+ execFileMock.mockReset();
27
+ execFileMock.mockImplementation(
28
+ (command: string, args: string[], options: any, callback: ExecCallback) => {
29
+ recorded.push({ command, args, options });
30
+ callback(
31
+ nextResponse.error ?? null,
32
+ nextResponse.stdout ?? "",
33
+ nextResponse.stderr ?? ""
34
+ );
35
+ }
36
+ );
37
+ });
38
+
39
+ describe("runCli", () => {
40
+ it("returns exitCode 0 with stdout/stderr on success", async () => {
41
+ nextResponse = { stdout: "done\n", stderr: "" };
42
+ const result = await runCli("jui", ["build"], { cwd: "/proj" });
43
+ expect(result).toEqual({ exitCode: 0, stdout: "done\n", stderr: "" });
44
+ });
45
+
46
+ it("propagates the process exit code from the error object", async () => {
47
+ nextResponse = {
48
+ error: Object.assign(new Error("exit 3"), { code: 3 }),
49
+ stdout: "partial",
50
+ stderr: "boom",
51
+ };
52
+ const result = await runCli("jui", ["verify"], { cwd: "/proj" });
53
+ expect(result).toEqual({ exitCode: 3, stdout: "partial", stderr: "boom" });
54
+ });
55
+
56
+ it("defaults to exitCode 1 when the error has no code", async () => {
57
+ nextResponse = { error: new Error("spawn failure") };
58
+ const result = await runCli("jui", ["build"], { cwd: "/proj" });
59
+ expect(result.exitCode).toBe(1);
60
+ });
61
+
62
+ it("maps a killed process to a timeout message using the default 60s", async () => {
63
+ nextResponse = {
64
+ error: Object.assign(new Error("killed"), { killed: true }),
65
+ stdout: "partial output",
66
+ };
67
+ const result = await runCli("jui", ["build"], { cwd: "/proj" });
68
+ expect(result).toEqual({
69
+ exitCode: 1,
70
+ stdout: "partial output",
71
+ stderr: "Command timed out after 60s",
72
+ });
73
+ });
74
+
75
+ it("reports custom timeouts in the timeout message", async () => {
76
+ nextResponse = {
77
+ error: Object.assign(new Error("killed"), { killed: true }),
78
+ };
79
+ const result = await runCli("jui", ["build"], { cwd: "/proj", timeout: 5_000 });
80
+ expect(result.stderr).toBe("Command timed out after 5s");
81
+ });
82
+
83
+ it("forwards cwd / timeout / maxBuffer / UTF-8 env to execFile", async () => {
84
+ await runCli("jsonui-doc", ["validate", "spec", "x.json"], {
85
+ cwd: "/some/project",
86
+ timeout: 12_345,
87
+ });
88
+ expect(recorded).toHaveLength(1);
89
+ const { command, args, options } = recorded[0];
90
+ expect(command).toBe("jsonui-doc");
91
+ expect(args).toEqual(["validate", "spec", "x.json"]);
92
+ expect(options.cwd).toBe("/some/project");
93
+ expect(options.timeout).toBe(12_345);
94
+ expect(options.maxBuffer).toBe(10 * 1024 * 1024);
95
+ expect(options.env.PYTHONIOENCODING).toBe("utf-8");
96
+ });
97
+
98
+ it("uses a 60s timeout when none is given", async () => {
99
+ await runCli("jui", ["build"], { cwd: "/proj" });
100
+ expect(recorded[0].options.timeout).toBe(60_000);
101
+ });
102
+ });
103
+
104
+ describe("formatResult", () => {
105
+ it("marks exitCode 0 as success and omits empty errors", () => {
106
+ const result: CliResult = { exitCode: 0, stdout: "ok", stderr: "" };
107
+ const parsed = JSON.parse(formatResult(result));
108
+ expect(parsed).toEqual({ success: true, output: "ok" });
109
+ expect("errors" in parsed).toBe(false);
110
+ });
111
+
112
+ it("marks non-zero exit as failure and includes stderr", () => {
113
+ const result: CliResult = { exitCode: 2, stdout: "log", stderr: "bad" };
114
+ expect(JSON.parse(formatResult(result))).toEqual({
115
+ success: false,
116
+ output: "log",
117
+ errors: "bad",
118
+ });
119
+ });
120
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * ServerConfig — project dir resolution, jui.config.json reading,
3
+ * directory field resolution, and path-traversal defense.
4
+ */
5
+ import { mkdirSync, symlinkSync, writeFileSync } from "fs";
6
+ import { join, resolve } from "path";
7
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
+
9
+ import { ServerConfig, type ProjectConfig } from "../src/config.js";
10
+ import {
11
+ cleanupTempDirs,
12
+ makeFixtureProject,
13
+ makeTempDir,
14
+ FIXTURE_PROJECT_CONFIG,
15
+ } from "./helpers.js";
16
+
17
+ beforeEach(() => {
18
+ delete process.env.JUI_PROJECT_DIR;
19
+ });
20
+
21
+ afterEach(() => {
22
+ delete process.env.JUI_PROJECT_DIR;
23
+ cleanupTempDirs();
24
+ });
25
+
26
+ describe("resolveProjectDir", () => {
27
+ it("uses the explicit override when given", () => {
28
+ const dir = makeTempDir("proj");
29
+ const config = new ServerConfig();
30
+ expect(config.resolveProjectDir(dir)).toBe(resolve(dir));
31
+ });
32
+
33
+ it("falls back to JUI_PROJECT_DIR captured at construction time", () => {
34
+ const dir = makeTempDir("proj");
35
+ process.env.JUI_PROJECT_DIR = dir;
36
+ const config = new ServerConfig();
37
+ expect(config.resolveProjectDir()).toBe(resolve(dir));
38
+ });
39
+
40
+ it("prefers the override over JUI_PROJECT_DIR", () => {
41
+ const envDir = makeTempDir("env-proj");
42
+ const overrideDir = makeTempDir("override-proj");
43
+ process.env.JUI_PROJECT_DIR = envDir;
44
+ const config = new ServerConfig();
45
+ expect(config.resolveProjectDir(overrideDir)).toBe(resolve(overrideDir));
46
+ });
47
+
48
+ it("throws a setup hint when neither override nor env is present", () => {
49
+ const config = new ServerConfig();
50
+ expect(() => config.resolveProjectDir()).toThrowError(
51
+ /No project directory specified.*JUI_PROJECT_DIR/s
52
+ );
53
+ });
54
+
55
+ it("throws when the directory does not exist", () => {
56
+ const config = new ServerConfig();
57
+ const missing = join(makeTempDir("base"), "does-not-exist");
58
+ expect(() => config.resolveProjectDir(missing)).toThrowError(
59
+ /Project directory does not exist/
60
+ );
61
+ });
62
+ });
63
+
64
+ describe("readProjectConfig", () => {
65
+ it("parses jui.config.json from the project root", () => {
66
+ const dir = makeTempDir("proj");
67
+ makeFixtureProject(dir);
68
+ const config = new ServerConfig();
69
+ const project = config.readProjectConfig(dir);
70
+ expect(project.project_name).toBe("ExampleApp");
71
+ expect(project.spec_directory).toBe("docs/screens/json");
72
+ expect(project.platforms.ios.mode).toBe("swiftui");
73
+ });
74
+
75
+ it("throws with a jui_init hint when jui.config.json is missing", () => {
76
+ const dir = makeTempDir("empty-proj");
77
+ const config = new ServerConfig();
78
+ expect(() => config.readProjectConfig(dir)).toThrowError(
79
+ /jui\.config\.json not found.*jui_init/s
80
+ );
81
+ });
82
+ });
83
+
84
+ describe("resolveDir", () => {
85
+ const config = new ServerConfig();
86
+ const projectConfig = FIXTURE_PROJECT_CONFIG as unknown as ProjectConfig;
87
+
88
+ it("joins relative directory fields onto the project dir", () => {
89
+ expect(resolveDirFor("layouts_directory")).toBe(join("/proj", "layouts"));
90
+ expect(resolveDirFor("spec_directory")).toBe(
91
+ join("/proj", "docs/screens/json")
92
+ );
93
+ });
94
+
95
+ it("passes absolute directory fields through unchanged", () => {
96
+ const absConfig = {
97
+ ...projectConfig,
98
+ layouts_directory: "/abs/layouts",
99
+ } as ProjectConfig;
100
+ expect(config.resolveDir(absConfig, "layouts_directory", "/proj")).toBe(
101
+ "/abs/layouts"
102
+ );
103
+ });
104
+
105
+ it("falls back to the project dir for non-string fields", () => {
106
+ expect(config.resolveDir(projectConfig, "platforms", "/proj")).toBe("/proj");
107
+ });
108
+
109
+ function resolveDirFor(field: keyof ProjectConfig): string {
110
+ return config.resolveDir(projectConfig, field, "/proj");
111
+ }
112
+ });
113
+
114
+ describe("validatePathInProject", () => {
115
+ const config = new ServerConfig();
116
+
117
+ // Note: return values are asserted via suffix, not full equality — the
118
+ // absolute prefix may be symlink-normalized (macOS /var -> /private/var).
119
+ it("accepts a relative path inside the project", () => {
120
+ const dir = makeTempDir("proj");
121
+ const validated = config.validatePathInProject("layouts/login.json", dir);
122
+ expect(validated.endsWith(join("layouts", "login.json"))).toBe(true);
123
+ });
124
+
125
+ it("accepts an absolute path inside the project", () => {
126
+ const dir = makeTempDir("proj");
127
+ const inside = join(dir, "docs", "spec.json");
128
+ const validated = config.validatePathInProject(inside, dir);
129
+ expect(validated.endsWith(join("docs", "spec.json"))).toBe(true);
130
+ });
131
+
132
+ it("rejects ../ traversal out of the project", () => {
133
+ const dir = makeTempDir("proj");
134
+ expect(() =>
135
+ config.validatePathInProject("../../etc/passwd", dir)
136
+ ).toThrowError(/Path traversal detected/);
137
+ });
138
+
139
+ it("rejects ../ traversal hidden in the middle of a path", () => {
140
+ const dir = makeTempDir("proj");
141
+ expect(() =>
142
+ config.validatePathInProject("layouts/../../outside.json", dir)
143
+ ).toThrowError(/Path traversal detected/);
144
+ });
145
+
146
+ it("rejects absolute paths outside the project", () => {
147
+ const dir = makeTempDir("proj");
148
+ const other = makeTempDir("other");
149
+ expect(() =>
150
+ config.validatePathInProject(join(other, "secret.json"), dir)
151
+ ).toThrowError(/Path traversal detected/);
152
+ });
153
+
154
+ it("rejects sibling directories that share the project dir as a name prefix", () => {
155
+ // "/base/proj-evil/…".startsWith("/base/proj") is true — a naive prefix
156
+ // check lets siblings through. Must compare on path-segment boundaries.
157
+ const base = makeTempDir("base");
158
+ const dir = join(base, "proj");
159
+ const evil = join(base, "proj-evil");
160
+ mkdirSync(dir);
161
+ mkdirSync(evil);
162
+ writeFileSync(join(evil, "secret.json"), "{}");
163
+ expect(() =>
164
+ config.validatePathInProject(join(evil, "secret.json"), dir)
165
+ ).toThrowError(/Path traversal detected/);
166
+ });
167
+
168
+ it("rejects a symlink inside the project that points outside", () => {
169
+ const base = makeTempDir("base");
170
+ const dir = join(base, "proj");
171
+ const outside = join(base, "outside");
172
+ mkdirSync(dir);
173
+ mkdirSync(outside);
174
+ writeFileSync(join(outside, "secret.json"), "SECRET");
175
+ symlinkSync(outside, join(dir, "link"));
176
+ expect(() =>
177
+ config.validatePathInProject("link/secret.json", dir)
178
+ ).toThrowError(/Path traversal detected/);
179
+ });
180
+
181
+ it("rejects a non-existent leaf under an outward-pointing symlink", () => {
182
+ const base = makeTempDir("base");
183
+ const dir = join(base, "proj");
184
+ const outside = join(base, "outside");
185
+ mkdirSync(dir);
186
+ mkdirSync(outside);
187
+ symlinkSync(outside, join(dir, "link"));
188
+ expect(() =>
189
+ config.validatePathInProject("link/ghost.json", dir)
190
+ ).toThrowError(/Path traversal detected/);
191
+ });
192
+
193
+ it("accepts a symlink that stays inside the project", () => {
194
+ const base = makeTempDir("base");
195
+ const dir = join(base, "proj");
196
+ mkdirSync(join(dir, "real"), { recursive: true });
197
+ writeFileSync(join(dir, "real", "file.json"), "{}");
198
+ symlinkSync(join(dir, "real"), join(dir, "alias"));
199
+ const validated = config.validatePathInProject("alias/file.json", dir);
200
+ expect(validated.endsWith(join("real", "file.json"))).toBe(true);
201
+ });
202
+
203
+ it("allows a path that stays inside after .. segments resolve", () => {
204
+ const dir = makeTempDir("proj");
205
+ const validated = config.validatePathInProject(
206
+ "layouts/../docs/spec.json",
207
+ dir
208
+ );
209
+ expect(validated.endsWith(join("docs", "spec.json"))).toBe(true);
210
+ expect(validated).not.toContain("..");
211
+ });
212
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Derived static content served by the MCP: common-attribute categorizer and
3
+ * the platform convention constants. The constants are pinned via file
4
+ * snapshots — any edit to MODIFIER_ORDER / BINDING_RULES / PLATFORM_MAPPING
5
+ * must be intentional and reviewed via the snapshot diff.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import {
10
+ BINDING_RULES,
11
+ MODIFIER_ORDER,
12
+ PLATFORM_MAPPING,
13
+ categorizeCommonAttributes,
14
+ } from "../src/data/derived.js";
15
+
16
+ describe("categorizeCommonAttributes", () => {
17
+ it("places known keys into their category", () => {
18
+ const result = categorizeCommonAttributes({
19
+ width: { type: "int" },
20
+ padding: { type: "int" },
21
+ background: { type: "string" },
22
+ hidden: { type: "boolean" },
23
+ onClick: { type: "binding" },
24
+ alignTop: { type: "boolean" },
25
+ onAppear: { type: "binding" },
26
+ testId: { type: "string" },
27
+ binding_group: { type: "string" },
28
+ });
29
+ expect(result.categories.sizing.width).toBeDefined();
30
+ expect(result.categories.spacing.padding).toBeDefined();
31
+ expect(result.categories.visual.background).toBeDefined();
32
+ expect(result.categories.visibility.hidden).toBeDefined();
33
+ expect(result.categories.interaction.onClick).toBeDefined();
34
+ expect(result.categories.layout.alignTop).toBeDefined();
35
+ expect(result.categories.lifecycle.onAppear).toBeDefined();
36
+ expect(result.categories.accessibility.testId).toBeDefined();
37
+ expect(result.categories.binding.binding_group).toBeDefined();
38
+ });
39
+
40
+ it("routes unknown keys to 'other'", () => {
41
+ const result = categorizeCommonAttributes({ mysteryKnob: { type: "int" } });
42
+ expect(result.categories.other.mysteryKnob).toBeDefined();
43
+ });
44
+
45
+ it("skips _-prefixed keys", () => {
46
+ const result = categorizeCommonAttributes({
47
+ _meta: { version: 1 },
48
+ width: { type: "int" },
49
+ });
50
+ expect(result.categories.other).toBeUndefined();
51
+ expect(Object.keys(result.categories)).toEqual(["sizing"]);
52
+ });
53
+
54
+ it("drops empty categories entirely", () => {
55
+ const result = categorizeCommonAttributes({ width: { type: "int" } });
56
+ expect(Object.keys(result.categories)).toEqual(["sizing"]);
57
+ });
58
+
59
+ it("returns the wrapper description and source", () => {
60
+ const result = categorizeCommonAttributes({});
61
+ expect(result.description).toContain("Common attributes");
62
+ expect(result.source).toContain("attribute_definitions.json");
63
+ expect(result.categories).toEqual({});
64
+ });
65
+
66
+ it("preserves attribute definitions verbatim", () => {
67
+ const def = { type: ["string", "binding"], description: "w" };
68
+ const result = categorizeCommonAttributes({ width: def });
69
+ expect(result.categories.sizing.width).toBe(def);
70
+ });
71
+ });
72
+
73
+ describe("derived constants (pinned)", () => {
74
+ it("MODIFIER_ORDER covers exactly swift/kotlin/react", () => {
75
+ expect(Object.keys(MODIFIER_ORDER).sort()).toEqual([
76
+ "description",
77
+ "kotlin",
78
+ "react",
79
+ "swift",
80
+ ]);
81
+ expect(MODIFIER_ORDER.swift.order.length).toBeGreaterThan(0);
82
+ expect(MODIFIER_ORDER.swift.criticalRules.length).toBeGreaterThan(0);
83
+ expect(MODIFIER_ORDER.kotlin.order.length).toBeGreaterThan(0);
84
+ });
85
+
86
+ it("BINDING_RULES defines both directions and the @{} syntax", () => {
87
+ expect(BINDING_RULES.format.bindingExpression).toBe("@{propertyName}");
88
+ expect(Object.keys(BINDING_RULES.directions).sort()).toEqual([
89
+ "read-only",
90
+ "two-way",
91
+ ]);
92
+ });
93
+
94
+ it("PLATFORM_MAPPING has all three platforms for every plain value mapping", () => {
95
+ for (const mapping of Object.values(PLATFORM_MAPPING.values)) {
96
+ expect(Object.keys(mapping).sort()).toEqual(["kotlin", "react", "swift"]);
97
+ }
98
+ for (const mapping of Object.values(PLATFORM_MAPPING.textAlign)) {
99
+ expect(Object.keys(mapping).sort()).toEqual(["kotlin", "react", "swift"]);
100
+ }
101
+ });
102
+
103
+ it("MODIFIER_ORDER snapshot", () => {
104
+ expect(MODIFIER_ORDER).toMatchSnapshot();
105
+ });
106
+
107
+ it("BINDING_RULES snapshot", () => {
108
+ expect(BINDING_RULES).toMatchSnapshot();
109
+ });
110
+
111
+ it("PLATFORM_MAPPING snapshot", () => {
112
+ expect(PLATFORM_MAPPING).toMatchSnapshot();
113
+ });
114
+ });