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,114 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "_comment": "Declared attributes no platform converter reads. Hand-maintained: close a gap by implementing it (then delete the entry) or by narrowing platform/mode in attribute_definitions.json. reason is one of: dynamic-key, legacy, platform-na, runtime-only, unimplemented.",
4
+ "entries": [
5
+ {
6
+ "component": "Button",
7
+ "attribute": "hilightColor",
8
+ "platforms": [
9
+ "web"
10
+ ],
11
+ "reason": "legacy",
12
+ "note": "alias — normalized to its canonical spelling before conversion"
13
+ },
14
+ {
15
+ "component": "Collection",
16
+ "attribute": "onPageChanged",
17
+ "platforms": [
18
+ "web"
19
+ ],
20
+ "reason": "legacy",
21
+ "note": "alias — normalized to its canonical spelling before conversion"
22
+ },
23
+ {
24
+ "component": "Radio",
25
+ "attribute": "selected_icon",
26
+ "platforms": [
27
+ "android"
28
+ ],
29
+ "reason": "legacy",
30
+ "note": "alias — normalized to its canonical spelling before conversion"
31
+ },
32
+ {
33
+ "component": "ScrollView",
34
+ "attribute": "minZoom",
35
+ "platforms": [
36
+ "ios"
37
+ ],
38
+ "reason": "unimplemented",
39
+ "note": "codegen 未対応。ランタイム側に実装あり (ios: UIKit) — この検査は Ruby codegen しか走査しないので、実装の有無ではなく codegen 対応の有無を表す"
40
+ },
41
+ {
42
+ "component": "Slider",
43
+ "attribute": "maxValue",
44
+ "platforms": [
45
+ "web"
46
+ ],
47
+ "reason": "legacy",
48
+ "note": "alias — normalized to its canonical spelling before conversion"
49
+ },
50
+ {
51
+ "component": "Slider",
52
+ "attribute": "minValue",
53
+ "platforms": [
54
+ "web"
55
+ ],
56
+ "reason": "legacy",
57
+ "note": "alias — normalized to its canonical spelling before conversion"
58
+ },
59
+ {
60
+ "component": "Switch",
61
+ "attribute": "onToggle",
62
+ "platforms": [
63
+ "web"
64
+ ],
65
+ "reason": "legacy",
66
+ "note": "alias — normalized to its canonical spelling before conversion"
67
+ },
68
+ {
69
+ "component": "TextField",
70
+ "attribute": "accessoryBackground",
71
+ "platforms": [
72
+ "ios"
73
+ ],
74
+ "reason": "unimplemented",
75
+ "note": "codegen 未対応。ランタイム側に実装あり (ios: UIKit) — この検査は Ruby codegen しか走査しないので、実装の有無ではなく codegen 対応の有無を表す"
76
+ },
77
+ {
78
+ "component": "TextField",
79
+ "attribute": "accessoryTextColor",
80
+ "platforms": [
81
+ "ios"
82
+ ],
83
+ "reason": "unimplemented",
84
+ "note": "codegen 未対応。ランタイム側に実装あり (ios: UIKit) — この検査は Ruby codegen しか走査しないので、実装の有無ではなく codegen 対応の有無を表す"
85
+ },
86
+ {
87
+ "component": "TextField",
88
+ "attribute": "doneText",
89
+ "platforms": [
90
+ "ios"
91
+ ],
92
+ "reason": "unimplemented",
93
+ "note": "codegen 未対応。ランタイム側に実装あり (ios: UIKit) — この検査は Ruby codegen しか走査しないので、実装の有無ではなく codegen 対応の有無を表す"
94
+ },
95
+ {
96
+ "component": "View",
97
+ "attribute": "direction",
98
+ "platforms": [
99
+ "ios"
100
+ ],
101
+ "reason": "unimplemented",
102
+ "note": "codegen 未対応。ランタイム側に実装あり (ios: UIKit) — この検査は Ruby codegen しか走査しないので、実装の有無ではなく codegen 対応の有無を表す"
103
+ },
104
+ {
105
+ "component": "common",
106
+ "attribute": "alpha",
107
+ "platforms": [
108
+ "web"
109
+ ],
110
+ "reason": "legacy",
111
+ "note": "alias — normalized to its canonical spelling before conversion"
112
+ }
113
+ ]
114
+ }
@@ -0,0 +1,350 @@
1
+ {
2
+ "version": 1,
3
+ "title": "JsonUI screen identity and navigation-assertion semantics (canonical)",
4
+ "notes": "Hand-authored canonical source (screen-identity track, declared 2026-07-25). This file IS the SSoT for what a 'screen' is, how a screen is identified across specs / layouts / tests / generated code, and how a screen's presence is asserted at runtime. Generators, validators, drivers and the flow-diagram generator conform to it, not the other way around. Canonical choices are design decisions, not majority votes of existing implementations.",
5
+ "screenId": {
6
+ "source": "layoutBasename",
7
+ "description": "A screen's canonical id is the basename of its layout file with the '.json' extension removed, kept verbatim (snake_case). The same id is used by spec files, screen/flow tests, generated markers and diagram nodes.",
8
+ "collection": "recursive",
9
+ "collectionNote": "Layout directories are scanned RECURSIVELY (Layouts/**/*.json). A flat glob under-collects: real projects nest sheets and cells one level deep (measured: 108 layouts, only 40 at the top level).",
10
+ "nonLayoutSubtrees": [
11
+ "Resources",
12
+ "Styles"
13
+ ],
14
+ "nonLayoutSubtreesNote": "Directories under the layout root that hold resources, not layouts: 'Resources/' (strings.json, colors.json, and the per-locale files a build generates beside them) and 'Styles/'. A JSON file at any depth inside one is skipped entirely — neither classified nor scanned for references. Without this, nothing references them, so step 4 defaults them to SCREEN. Measured on a real project: two phantom screens ('strings', 'colors') in the authored tree and four in the generated tree, which also made the generator's count disagree with 'jui screens' by exactly the two generated locale files. Every other place in the toolchain that collects layouts already excludes these subtrees — spelled ten different ways; the screen resolver is the reader that has to agree with them, so the rule is named here once. Depth is ANY depth under the layout root, matching the majority of those spellings: a directory called Resources means resources wherever it sits, and the cost of being wrong that way (an excluded file the author can rename) is smaller than the cost of the alternative (a phantom screen that grows a runtime marker). Known divergence: the build's copy/prune step still skips only TOP-LEVEL Resources/Styles. The two agree on every project surveyed, where these directories only ever appear at the layout root; unifying the remaining spellings on this rule is follow-up work.",
15
+ "uniqueness": "projectWide",
16
+ "uniquenessNote": "Basenames must be unique across the whole layout tree; a collision makes the id ambiguous and is a validator error (rule: screen-id-collision).",
17
+ "variantNormalization": {
18
+ "pattern": "<base>@<class>.json",
19
+ "classes": [
20
+ "compact",
21
+ "medium",
22
+ "regular"
23
+ ],
24
+ "rule": "Variant files normalize to the base id ('home@regular' -> 'home'). Variants are alternate renderings of the same screen, never a separate screen."
25
+ }
26
+ },
27
+ "screenClassification": {
28
+ "description": "Not every layout is a screen. Only screens carry a marker, are valid 'screen' values in tests, and become diagram nodes.",
29
+ "roles": [
30
+ "screen",
31
+ "cell",
32
+ "partial"
33
+ ],
34
+ "resolutionOrder": [
35
+ {
36
+ "step": 1,
37
+ "rule": "explicitRole",
38
+ "detail": "If the layout root declares \"role\": \"screen\" | \"cell\" | \"partial\", that declaration wins. The explicit declaration is canonical; derivation only fills in what is not declared."
39
+ },
40
+ {
41
+ "step": 2,
42
+ "rule": "referencedAsNonScreen",
43
+ "detail": "A layout referenced by ANY other layout through 'cell', 'header', 'footer', 'cellClasses' or 'include' is not a screen (it is instantiated inside another layout, potentially once per data row).",
44
+ "referenceKeys": [
45
+ "cell",
46
+ "header",
47
+ "footer",
48
+ "cellClasses",
49
+ "include"
50
+ ]
51
+ },
52
+ {
53
+ "step": 3,
54
+ "rule": "partialFlag",
55
+ "detail": "A layout with \"partial\": true is not a screen. This is a sufficient condition only — it is under-used in practice (measured: 2 of 108 layouts), so it must never be treated as the necessary condition."
56
+ },
57
+ {
58
+ "step": 4,
59
+ "rule": "defaultScreen",
60
+ "detail": "Anything left over is classified as a screen. Derivation is deliberately imperfect: tools MUST report the derived classification so authors can correct outliers with an explicit role (step 1)."
61
+ }
62
+ ],
63
+ "derivationBlindSpot": {
64
+ "description": "Step 2 only sees references written in layout JSON. A cell instantiated from host-language code — a CellBuilder, or a ViewModel assembling cellClasses at runtime — is referenced by no layout, so it reaches step 4 and becomes a screen. It then carries a marker inside its host screen's tree, which breaks marker.invariants[0] (exactly one marker per rendered screen). This is a blind spot of the derivation, not a bug in it: the information is not present in the layouts.",
65
+ "resolution": "Declare \"role\": \"cell\" on the layout. Step 1 exists for exactly this case.",
66
+ "reportingRule": "The set a tool must present for review is EVERY screen whose reason is 'default' (derivedScreens). A name-based hint over that set (screens_needing_review) may be offered as a convenience, but never as a complete list: measured on a real project it flagged 7 while missing 8 more wrongly-derived screens, and a consumer who fixed only the 7 was left with markers on non-screens and no way to notice.",
67
+ "rejectedSignals": [
68
+ {
69
+ "signal": "layout name shape (_cell, _row, _item …)",
70
+ "verdict": "hint only",
71
+ "measurement": "Catches 7 of 15 wrongly-derived screens on the measured project. Cells built from host code are named bar_card / holidays_section / indicator_dot and are missed."
72
+ },
73
+ {
74
+ "signal": "a screen spec exists with the same basename",
75
+ "verdict": "rejected",
76
+ "measurement": "0 of 55 screens on the measured project had a spec whose basename matched, so the signal separates nothing."
77
+ },
78
+ {
79
+ "signal": "the layout id appears in host-language source",
80
+ "verdict": "rejected",
81
+ "measurement": "48 of 55 derived screens are named in host source — every screen has a ViewModel named after it, exactly as every code-built cell does. 6 of 8 known cells hit, but so did 42 non-cells: ~12% precision."
82
+ }
83
+ ],
84
+ "preciseDetection": "The only exact detector is runtime: a screen whose tree exposes markers for two or more ids has at least one non-screen among them. Legitimate co-presentation (split-pane and tab hosts, see assertion semantics) means this must be reported as a warning with the ids listed, not a failure. Not yet implemented — follow-up on the driver side."
85
+ }
86
+ },
87
+ "appOwnedScreens": {
88
+ "description": "Screens the app owns outright — a hand-written page with no JsonUI layout behind it. They are real navigation destinations (a test navigates to them and asserts there), so they must be declarable, otherwise the screen-unknown rule rejects a legitimate id.",
89
+ "declaration": {
90
+ "location": "jui.config.json",
91
+ "key": "test.appOwnedScreens",
92
+ "shape": "Array of entries. An entry is a screen id, or an object { id, group } when the screen also needs diagram metadata. Both forms are equivalent for classification.",
93
+ "example": [
94
+ "tokushoho",
95
+ {
96
+ "id": "company",
97
+ "group": "static"
98
+ },
99
+ {
100
+ "id": "licenses",
101
+ "group": [
102
+ "static",
103
+ "legal"
104
+ ]
105
+ }
106
+ ],
107
+ "group": {
108
+ "value": "A group name, or an array of them — the same shape a screen test's metadata.group takes.",
109
+ "readBy": "The flow-diagram generator only. Classification and code generation read the id and ignore the rest of the object.",
110
+ "rationale": "Every other screen declares its diagram group in its screen test (metadata.group). An app-owned screen has no layout, so there is nothing to write a screen test against, so the declaration is the only place it can carry one. Without it such a screen sits in the ungrouped bucket permanently — and that bucket has to keep meaning 'nobody declared this yet', or the screens that genuinely need attention are hidden among the ones that can never leave.",
111
+ "precedence": "A screen test's own metadata.group wins; the declaration only fills in for a screen whose tests declare none. One screen, one place to look."
112
+ },
113
+ "readerCompatibility": "Both canon readers must TOLERATE the object form even though only the doc tool uses the group: a reader that skipped non-string entries would leave a declared screen unknown to the validator while code generation still saw it."
114
+ },
115
+ "marker": "Hand-written by the app, using the same marker spelling code generation emits, so drivers cannot tell the two apart. On web that is the data-screen attribute on the page's root element.",
116
+ "libraryApi": "None. No mobile app-owned screen exists in any consumer today; when one appears, expose the mechanism from marker.platforms as a function then — not before.",
117
+ "classification": "app-owned"
118
+ },
119
+ "marker": {
120
+ "namePattern": "__screen_<screenId>",
121
+ "prefix": "__screen_",
122
+ "prefixOwnership": "The RUNTIME layer forms the marker name from a bare screen id: SwiftJsonUI's .jsonUIScreenMarker(id), KotlinJsonUI's ScreenMarker(id). Code generation therefore passes the SCREEN ID, never an already-prefixed marker — doing both produced __screen___screen_<id> on a real device (caught by the Android smoke test, 2026-07-27). On web there is no runtime layer, so the generated data-screen attribute carries the bare id and the driver matches [data-screen=\"<screenId>\"].",
123
+ "description": "Runtime identity beacon emitted by code generation into every screen, and only into screens. Tests never spell the marker: it is an implementation detail owned by the drivers.",
124
+ "invariants": [
125
+ "A rendered screen exposes EXACTLY ONE marker. Zero markers means the generated code or the library pin is stale; two or more means a generator bug (variant struct, double-drive) — both are hard errors, not warnings.",
126
+ "The marker is a DEDICATED NODE, never an identifier applied to the layout's root node. Every platform has a single identifier slot per node (iOS accessibilityIdentifier, Compose testTag, web root id), and the root's own id is load-bearing for existing tests (measured: 73 of 108 layouts declare a root id).",
127
+ "The marker is emitted on every rendered branch of a screen (a root-level responsive split produces more than one static branch).",
128
+ "Variant structs do NOT emit a marker; the base emits it once.",
129
+ "Static and dynamic (hot reload) rendering paths must both emit the marker; a mode-dependent marker splits test results by rendering mode.",
130
+ "Dynamic renderers receive the marker from the screen call site. Auto-attaching it at the DynamicView entry point is forbidden: cells, tabs, embeds, includes and dialogs re-enter the same entry point and would each grow a false marker."
131
+ ],
132
+ "platforms": {
133
+ "ios-swiftui": {
134
+ "mechanism": "overlay leaf",
135
+ "detail": "A 0.5x0.5 Color.clear leaf in an overlay(alignment: .center) carrying .accessibilityElement(children: .ignore) + .accessibilityIdentifier(marker), plus .allowsHitTesting(false) so it never takes a real touch. Deliberately NOT an accessibility container: making the screen root a container merges single-child subtrees and drops the inner element's identifier (measured regression, 2026-07-21)."
136
+ },
137
+ "ios-uikit": {
138
+ "mechanism": "sibling view",
139
+ "detail": "A 1x1 empty UIView added to the root view with accessibilityIdentifier = marker. UIKit exposes a flat accessibility tree, so no container semantics are involved."
140
+ },
141
+ "android-compose": {
142
+ "mechanism": "sibling node",
143
+ "detail": "A 1.dp Box sibling carrying Modifier.testTag(marker) AND its own semantics { testTagsAsResourceId = true }, emitted inside a transparent Box(propagateMinConstraints = true) that wraps both rendering branches. The size is NOT cosmetic: a zero-size node is absent from the accessibility tree, and so is a sized child placed outside a zero-size parent (both measured) — the marker has to occupy real space. The wrapping Box is what makes that free: a Box sizes to its largest child, so the marker overlays the content instead of costing 1.dp of layout in the (dominant) Column-shaped screens. clearAndSetSemantics is forbidden on or above the marker — it erases the node. mergeDescendants was measured NOT to erase it on API 35.",
144
+ "minimumSize": "1.dp",
145
+ "selfSufficient": "The marker sets testTagsAsResourceId on ITSELF, so it does not depend on an ancestor opting in. Measured: under an ancestor with testTagsAsResourceId = false the marker was still found by resource id while a sibling relying on the ancestor was not."
146
+ },
147
+ "web-react": {
148
+ "mechanism": "data attribute",
149
+ "detail": "A data-screen=\"<screenId>\" attribute on the generated root element — the same element that carries the root id, so the marker is visible exactly when the screen is. A dedicated node would need its own non-empty box to satisfy the driver's visibility predicate and would join the parent's flex/grid flow. The root 'id' itself is NOT usable: it is an externally supplied prop and callers already override it. Server-side rendering emits the attribute into the initial markup, so the marker is present before hydration (measured)."
150
+ }
151
+ },
152
+ "buildGating": {
153
+ "rule": "The marker is test scaffolding and must not reach a shipped app. Every platform emits it in development builds only.",
154
+ "platforms": {
155
+ "ios": "#if DEBUG inside the library modifier — the same gate ViewSwitcher uses for Dynamic mode. SwiftJsonUI is distributed as source (SPM / CocoaPods), so this follows the CONSUMER's build configuration.",
156
+ "android": "A RUNTIME check of the host application's ApplicationInfo.FLAG_DEBUGGABLE, not a compile-time one: KotlinJsonUI ships as an AAR, so its own BuildConfig.DEBUG describes how the library was built rather than how the app was. This is the same reason DynamicModeManager resolves availability from the host app's debuggable flag.",
157
+ "web": "A generated `screenMarker(id)` helper returns `{'data-screen': id}` in development and `{}` in production; the screen's root element spreads it. The check lives in ONE generated module rather than inline in every screen because a bare `process.env` reference fails to typecheck in a Vite project without @types/node (measured: TS2580) — the module declares the shape it needs. It must keep the literal `process.env.NODE_ENV` form: bundlers statically replace exactly that expression, so a production build folds the branch away, whereas a `globalThis.process` lookup would typecheck but NOT be replaced, and the marker would ship."
158
+ },
159
+ "consequence": "UI tests must run against a debug / development build. This is not a new constraint — Dynamic mode already requires it on both mobile platforms."
160
+ },
161
+ "placement": {
162
+ "rule": "The marker must be a sibling of the screen's scrollable, never a child of the scrolling content.",
163
+ "rationale": "A marker inside the scrolling content leaves the viewport when the user scrolls, and a node outside the viewport is absent from the accessibility tree: the screen would stop reporting itself as displayed halfway down a long page. Measured on Android (API 35) — after scrolling to the bottom, an in-content marker returned findObjects=0 / findObject=null / not-in-tree, while a sibling marker was unaffected. On iOS the overlay modifier applies to the screen root, which places the marker outside the scroll content by construction.",
164
+ "alignment": "center",
165
+ "alignmentRule": "The marker sits at the CENTRE of the screen root, never at a corner. A generated screen root fills the screen, so its top-left corner lies under the navigation bar and hit-testing there resolves to the chrome, not the marker — which fails the `exists && isHittable` predicate on every screen assertion. Measured on iOS 18.6, one run, one screen: the same 0.5pt leaf reported isHittable=false at .topLeading and isHittable=true at .center, while a sheet covering the screen still reported isHittable=false from the centre. The centre placement therefore fixes the false negative without costing the predicate its ability to detect a covered screen.",
166
+ "regressionNote": "This shipped as .topLeading in SwiftJsonUI 10.8.0 and broke every iOS screen assertion in a real app while the library's own probe stayed green — the probe marked a small view in the middle of a stack, where the corner is clear, and asserted only existence. Any probe for a runtime marker MUST assert the driver's exact predicate on a screen shaped the way code generation emits one; existence checks cannot catch a predicate bug.",
167
+ "alignmentAppliesTo": "iOS. The overlay modifier centres the marker inside the screen root. Android places the marker by padding instead (see systemBarChrome) because its failure is a different one: not hit-testing, but the accessibility framework's visible-region subtraction.",
168
+ "systemBarChrome": {
169
+ "appliesTo": "Android",
170
+ "rule": "The marker must be padded clear of the system-bar insets. A generated screen root sits at the window origin under edge-to-edge (targetSdk 35+), and the status bar is drawn over it.",
171
+ "rationale": "The accessibility framework subtracts the region covered by windows above the app. A node lying ENTIRELY inside that region reports isVisibleToUser=false, and UiAutomator's By.res — which IS the canonical Android predicate — does not return it, even though the node is present in the tree. Nothing is claiming to be a different screen there; the screen is plainly displayed, so this is a placement fault, not a predicate one.",
172
+ "measurements": {
173
+ "occluder": "The status bar WINDOW, not the inset: on the tablet AVD the window is Rect(0, 0 - 2560, 48) while WindowInsets.systemBars reports top=72. Sweeping the marker's y: invisible through y=46, visible from y=47 — exactly the window edge.",
174
+ "sizeIsNotAFix": "A 48.dp marker at the origin clears the bar on the tablet AVD (found) and is still entirely inside it on the phone AVD (not found). The required size is device-dependent, so only placement works.",
175
+ "paddingNotOffset": "Modifier.offset breaks the marker's OWN testTagsAsResourceId opt-in: measured with the offset before the tag, after the semantics, on an outer node, and with the opt-in moved to that outer node, the marker stopped being exposed as a resource id at all under an ancestor setting testTagsAsResourceId = false. Padding places the node and keeps it addressable.",
176
+ "afterFix": "tablet: found at y=72 | phone: found at y=132 | pre-fix shape on both: isVisibleToUser=false at (0,0), By.res finds 0"
177
+ },
178
+ "regressionNote": "This shipped as origin-placement in KotlinJsonUI 2.15.0 and failed every Android screen-transition test in a real app while the library's own probe stayed green — every probe mode ran under a root that padded the system bars away, so the one placement code generation actually emits was never measured. Same shape of blind spot as the iOS .topLeading regression. A probe for a runtime marker MUST measure the screen shape code generation emits, chrome included.",
179
+ "minLibraryVersion": "2.15.1"
180
+ }
181
+ },
182
+ "shapeEvidence": "Measured on iOS 18.6 (2026-07-27) over identical content: the LEAF shape keeps the screen's own root identifier and its children addressable; the CONTAINER shape wrapping the same subtree makes the screen's root identifier disappear (root_id_visible=false). The container shape is therefore rejected, not merely discouraged. On Android (API 35, 2026-07-27) the same probe measured the size floor and the ancestor-semantics hazards recorded above."
183
+ },
184
+ "assertion": {
185
+ "vocabulary": {
186
+ "assert": "screen",
187
+ "targetKey": "name",
188
+ "optionalKeys": [
189
+ "timeout",
190
+ "label",
191
+ "optional",
192
+ "when"
193
+ ],
194
+ "example": {
195
+ "assert": "screen",
196
+ "name": "mypage",
197
+ "timeout": 10000
198
+ }
199
+ },
200
+ "targetKeyNote": "The target key is 'name' (mirroring screenshotAssertion). The step-level 'screen' key means 'the screen this step runs on' and must not be overloaded as the assertion target — during a transition the two legitimately differ.",
201
+ "semantics": "The named screen IS DISPLAYED. It does not assert that the screen is displayed EXCLUSIVELY: embedded screens, split-pane layouts and tab hosts legitimately show several markers at once.",
202
+ "timing": "A marker means the destination has been CONSTRUCTED, not that the transition animation has settled. Both iOS NavigationStack and Android NavHost compose source and destination simultaneously for the duration of the transition.",
203
+ "rejected": {
204
+ "waitForScreen": "Assertions already auto-wait; a separate wait action would be a second spelling of one concept.",
205
+ "should_navigate_to": "Vocabulary is camelCase with action/assert separated.",
206
+ "exclusive": "No measured demand; asserting exclusivity requires window scoping that no driver has."
207
+ }
208
+ },
209
+ "predicates": {
210
+ "note": "Per-platform predicates for 'the marker is displayed'. Platform differences live here because the underlying accessibility runtimes genuinely differ; everything above this section is platform-neutral.",
211
+ "ios": {
212
+ "predicate": "exists && (isHittable || no OTHER screen marker is hittable)",
213
+ "rationale": "Measured on iOS 18.6 (ConformanceHost screen-marker probe, 2026-07-27). A pushed-away screen and an inactive tab leave the accessibility tree entirely (exists == false); a screen covered by a sheet or a fullScreenCover STAYS in the tree but reports isHittable == false, while the covering screen reports true. isHittable works here only because the marker is a LEAF: an accessibility container would report false even when fully visible, which is why the container marker shape is rejected. isHittable ALONE is not sufficient, and the second clause is not a softening. The marker sits inside the generated view by construction, so any overlay the app layers OUTSIDE that view — a coach mark, a loading scrim, a hand-rolled modal — always covers the marker even when it is fully transparent, and the screen is plainly still the one on display. What distinguishes the two cases is not reachability but whether something else CLAIMS to be the displayed screen: a sheet or cover is another screen and carries its own marker, an app overlay carries none. Measured both ways in one run: with a full-screen tap-catching overlay the marker reports isHittable=false and no other screen marker is hittable (=> displayed); with a sheet the presenter reports isHittable=false and the sheet's own marker IS hittable (=> not displayed).",
214
+ "measurements": {
215
+ "push": "covered screen: exists=false | covering: exists=true hittable=true",
216
+ "sheet": "covered: exists=true hittable=false | covering: exists=true hittable=true",
217
+ "fullScreenCover": "covered: exists=true hittable=false | covering: exists=true hittable=true",
218
+ "tabSwitch": "inactive tab: exists=false | active tab: exists=true hittable=true",
219
+ "splitPane": "both panes: exists=true hittable=true (both are displayed — this is correct)",
220
+ "appLevelOverlay": "generated view wrapped from outside by a full-screen contentShape(Rectangle()) overlay: marker exists=true isHittable=false, other hittable screens = [] => displayed",
221
+ "sheetUnderNewPredicate": "presenter: exists=true isHittable=false, other hittable screens = ['fullbleed_sheet'] => not displayed"
222
+ },
223
+ "status": "measured",
224
+ "appliesTo": "iOS only. Android's predicate is existence in the accessibility tree and web's is Playwright visibility (CSS/bounding box); neither is occlusion-sensitive, so neither needs the second clause."
225
+ },
226
+ "android": {
227
+ "predicate": "device.findObject(By.res(marker)) != null",
228
+ "rationale": "Measured on Android 15 / API 35 (KotlinJsonUI conformance-host screen-marker probe, 2026-07-27), identically with animations on and off. A covered screen leaves the search ENTIRELY: under an AlertDialog and under a ModalBottomSheet the presenter's marker reported findObjects=0, findObject=null and was absent from rootInActiveWindow — Android drops the obscured window's content from the accessibility tree. A NavHost destination likewise replaces its source. No visibleBounds test and no active-window scoping are needed, so the predicate is exactly the primitive the driver already uses for every other element.",
229
+ "measurements": {
230
+ "navHostPush": "source: not found | destination: found (identical with animation scales 1 and 0)",
231
+ "dialog": "covered: findObjects=0 findObject=null inTree=false | covering: found",
232
+ "modalBottomSheet": "covered: findObjects=0 findObject=null inTree=false | covering: found",
233
+ "splitPane": "both panes found (both are displayed — this is correct)",
234
+ "scrolledOutOfViewport": "marker inside the scrolling content: not found after scrolling to the bottom | sibling marker: unaffected (see marker.placement)"
235
+ },
236
+ "supersedes": "The earlier provisional predicate assumed a whole-device search would report a covered screen as present. Measurement refuted it: the covered screen is not reachable by any of the three search modes.",
237
+ "transitionNote": "UiAutomator queries synchronize on window idle, so the interval in which NavHost has both source and destination composed is not observable through the driver's own API; sampling immediately after the click already showed the source gone.",
238
+ "status": "measured"
239
+ },
240
+ "web": {
241
+ "predicate": "[data-screen=\"<screenId>\"] attached && Playwright-visible",
242
+ "rationale": "Measured with React 19 SSR + hydration under Playwright/Chromium (conformance web host screen-marker probe, 2026-07-27). Neither a plain client-side swap nor a suspending transition ever exposed two screens' markers at once, and a suspending transition never mounted the destination's marker early — while it was pending, NO marker was present at all. So the predicate needs no exclusivity test and no transition handling.",
243
+ "measurements": {
244
+ "ssrBeforeHydration": "data-screen is in the server markup: count=1, isVisible=true, hydrated=false",
245
+ "interactivityGap": "a click in that window is DROPPED (counter stayed 0); the same click after hydration works",
246
+ "clientSideSwap": "per-frame sampling: probe_home -> detail_screen, never both",
247
+ "suspendingTransition": "source unmounts to the Suspense fallback (no marker at all for ~1.2s), then the destination appears; the destination marker never appears early"
248
+ },
249
+ "hydrationLimitation": "The marker is visible before the page is interactive, so on the FIRST document load a screen assertion can pass while clicks are still being dropped. This is deliberately NOT part of the predicate: React exposes no standard 'hydrated' signal, and canonizing a gate no driver can implement would be worse than naming the gap. It does not recur on client-side navigation, which happens after hydration.",
250
+ "status": "measured"
251
+ },
252
+ "statusNote": "'provisional' predicates must be confirmed by device measurement before default-on verification is enabled; the measured result replaces the predicate here. As of 2026-07-27 all three platforms are measured."
253
+ },
254
+ "implicitVerification": {
255
+ "scope": "flowInlineStepScreenChange",
256
+ "description": "In a flow test, the driver verifies the marker at each point where the inline step's 'screen' differs from the previously executed step's screen.",
257
+ "rules": [
258
+ "Steps skipped by a 'when' condition do not advance the tracked screen.",
259
+ "A file-reference step carries no screen: reset the tracker to unknown so the next inline step is verified.",
260
+ "Steps inside a block continue the enclosing screen.",
261
+ "Screens that legitimately co-present (split-pane hosts, tab hosts) may opt out per screen."
262
+ ],
263
+ "timeout": {
264
+ "key": "screenTransitionTimeout",
265
+ "defaultMs": 10000,
266
+ "rationale": "Distinct from defaultTimeout (5000ms): real cross-screen assertions in production suites already use 15000-20000ms after a cold start."
267
+ },
268
+ "optOut": {
269
+ "config": "TestRunnerConfig.verifyScreenTransitions",
270
+ "default": true,
271
+ "note": "ON by default from driver ios 1.9.0 / android 1.8.0 / web 1.8.0. An app whose generated code predates screen markers fails every screen change with marker-absent — that is the intended signal that it needs `jui build` and a current library pin, not a reason to soften the check. A project mid-migration opts out by setting this to false."
272
+ },
273
+ "failureHandling": {
274
+ "severity": "failure",
275
+ "note": "Every class below fails the step through the ordinary assertion path. There is no warning tier and no per-class severity: a class is a prefix on the thrown failure message that names the likely CAUSE, so the reader knows what to fix — not how seriously to take it. This is what the three drivers implement (ScreenMarker.diagnosis on ios/android, AssertionExecutor.screenDiagnosis on web): they choose a string, then throw.",
276
+ "rationale": "A class was briefly considered for demotion to a warning (marker-absent, since its cause is the build rather than the app). Rejected: a suite that reports success while verifying nothing is worse than one that fails loudly on a stale build."
277
+ },
278
+ "failureClasses": [
279
+ {
280
+ "id": "marker-absent",
281
+ "meaning": "No marker anywhere. The cause is the build, not the navigation: a production build (markers are development-only), stale generated code, or a stale library pin. Fix with `jui build` and a current pin — the failure itself is not softened.",
282
+ "note": "Only reportable after the timeout expires. A suspending navigation legitimately shows NO marker while it is pending (measured on web: ~1.2s with the source unmounted to a fallback), so an instantaneous zero-marker reading is not evidence of stale code."
283
+ },
284
+ {
285
+ "id": "previous-screen-only",
286
+ "meaning": "Only the previous screen's marker is present — the navigation did not happen. The cause is in the app or the test, not the build."
287
+ },
288
+ {
289
+ "id": "marker-not-displayed",
290
+ "meaning": "Expected marker exists but fails the platform predicate — transition animation or predicate problem."
291
+ },
292
+ {
293
+ "id": "children-unresolvable",
294
+ "meaning": "Marker present but the screen's own element ids cannot be resolved — accessibility regression."
295
+ }
296
+ ]
297
+ },
298
+ "diagram": {
299
+ "description": "The screen-transition diagram is derived from the same ids.",
300
+ "nodeSource": "flow test inline step 'screen' values, plus file-reference steps normalized to the same id space",
301
+ "pipeline": [
302
+ "resolve",
303
+ "canonicalize",
304
+ "collapseConsecutiveDuplicates",
305
+ "buildEdges"
306
+ ],
307
+ "rules": [
308
+ "Self-loops are discarded (they are the collapse of a screen onto itself).",
309
+ "An edge caused by a 'back' action is a return edge and is rendered distinctly from a forward transition.",
310
+ "Node ids are sanitized for the diagram language; a screen id is never emitted raw into a diagram identifier position."
311
+ ],
312
+ "fileReferences": {
313
+ "rule": "A file-reference step ({ file, case }) becomes a node for the SCREEN the referenced test covers — resolved through that test's own source.layout — not for the referenced file's name.",
314
+ "rationale": "A test file NAME is not a screen id. One screen routinely has several test files (login_smoke, booking_complete--bank_pending), so naming the node after the file splits one screen into several nodes; the split-off node also carries no metadata, so it loses the group its test declared and drops into the ungrouped bucket. The basename only looked right because it usually happens to equal the screen id.",
315
+ "fallback": "A reference that resolves to no indexed test — broken, or pointing at a test with no source.layout — falls back to the basename, so it still draws a node rather than silently dropping the edge.",
316
+ "ambiguity": "When one file name is claimed by several tests covering DIFFERENT screens, nothing is resolved: picking one would silently label the node with the wrong screen."
317
+ },
318
+ "groups": {
319
+ "source": "A node's group comes from the screen tests covering it (metadata.group). For an app-owned screen, which has no test, it comes from its jui.config.json declaration (appOwnedScreens.declaration.group).",
320
+ "ungrouped": "Nodes with no group land in a catch-all bucket, which must keep reading as 'undeclared'. A screen with no way to declare a group would otherwise live there forever and hide the ones that simply have not been declared yet."
321
+ }
322
+ },
323
+ "validatorRules": [
324
+ {
325
+ "id": "screen-unknown",
326
+ "severity": "error",
327
+ "message": "A test's 'screen' value resolves to neither a layout nor a declared app-owned screen."
328
+ },
329
+ {
330
+ "id": "screen-not-a-screen",
331
+ "severity": "error",
332
+ "message": "A test's 'screen' value resolves to a layout classified as a cell or partial. Use the owning screen."
333
+ },
334
+ {
335
+ "id": "screen-id-collision",
336
+ "severity": "error",
337
+ "message": "Two layouts share a basename, making the screen id ambiguous."
338
+ },
339
+ {
340
+ "id": "screen-source-layout-missing",
341
+ "severity": "error",
342
+ "message": "A flow 'sources[].layout' path does not exist."
343
+ },
344
+ {
345
+ "id": "screen-alias-unreferenced",
346
+ "severity": "warning",
347
+ "message": "A flow declares a 'sources[].alias' that no step references."
348
+ }
349
+ ]
350
+ }
@@ -0,0 +1,10 @@
1
+ export interface CliResult {
2
+ exitCode: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+ export declare function runCli(command: string, args: string[], options: {
7
+ cwd: string;
8
+ timeout?: number;
9
+ }): Promise<CliResult>;
10
+ export declare function formatResult(result: CliResult): string;
@@ -0,0 +1,33 @@
1
+ import { execFile } from "child_process";
2
+ export async function runCli(command, args, options) {
3
+ const timeout = options.timeout ?? 60_000;
4
+ return new Promise((resolve) => {
5
+ execFile(command, args, {
6
+ cwd: options.cwd,
7
+ timeout,
8
+ maxBuffer: 10 * 1024 * 1024,
9
+ env: { ...process.env, PYTHONIOENCODING: "utf-8" },
10
+ }, (error, stdout, stderr) => {
11
+ if (error && "killed" in error && error.killed) {
12
+ resolve({
13
+ exitCode: 1,
14
+ stdout: stdout || "",
15
+ stderr: `Command timed out after ${timeout / 1000}s`,
16
+ });
17
+ return;
18
+ }
19
+ resolve({
20
+ exitCode: error ? error.code ?? 1 : 0,
21
+ stdout: stdout || "",
22
+ stderr: stderr || "",
23
+ });
24
+ });
25
+ });
26
+ }
27
+ export function formatResult(result) {
28
+ return JSON.stringify({
29
+ success: result.exitCode === 0,
30
+ output: result.stdout,
31
+ errors: result.stderr || undefined,
32
+ }, null, 2);
33
+ }
@@ -0,0 +1,19 @@
1
+ export interface ProjectConfig {
2
+ project_name: string;
3
+ spec_directory: string;
4
+ layouts_directory: string;
5
+ styles_directory: string;
6
+ images_directory: string;
7
+ component_spec_directory: string;
8
+ strings_file: string;
9
+ type_map_file: string;
10
+ platforms: Record<string, any>;
11
+ }
12
+ export declare class ServerConfig {
13
+ private defaultProjectDir;
14
+ constructor();
15
+ resolveProjectDir(override?: string): string;
16
+ readProjectConfig(projectDir: string): ProjectConfig;
17
+ resolveDir(config: ProjectConfig, field: keyof ProjectConfig, projectDir: string): string;
18
+ validatePathInProject(filePath: string, projectDir: string): string;
19
+ }
package/dist/config.js ADDED
@@ -0,0 +1,67 @@
1
+ import { readFileSync, existsSync, realpathSync } from "fs";
2
+ import { basename, dirname, join, resolve, isAbsolute, sep } from "path";
3
+ /**
4
+ * Resolve symlinks on the longest existing prefix of an already lexically
5
+ * resolved path. Non-existent trailing segments are kept as-is so validation
6
+ * can run before existence checks without throwing; a dangling or missing
7
+ * path can't leak data (the read fails afterwards).
8
+ */
9
+ function realpathLongestExistingPrefix(path) {
10
+ let existing = path;
11
+ const missingSuffix = [];
12
+ while (!existsSync(existing)) {
13
+ const parent = dirname(existing);
14
+ if (parent === existing)
15
+ return path; // no existing prefix at all
16
+ missingSuffix.unshift(basename(existing));
17
+ existing = parent;
18
+ }
19
+ return join(realpathSync(existing), ...missingSuffix);
20
+ }
21
+ export class ServerConfig {
22
+ defaultProjectDir;
23
+ constructor() {
24
+ this.defaultProjectDir = process.env.JUI_PROJECT_DIR;
25
+ }
26
+ resolveProjectDir(override) {
27
+ const dir = override || this.defaultProjectDir;
28
+ if (!dir) {
29
+ throw new Error("No project directory specified. Set JUI_PROJECT_DIR environment variable or pass project_dir parameter.");
30
+ }
31
+ const resolved = resolve(dir);
32
+ if (!existsSync(resolved)) {
33
+ throw new Error(`Project directory does not exist: ${resolved}`);
34
+ }
35
+ return resolved;
36
+ }
37
+ readProjectConfig(projectDir) {
38
+ const configPath = join(projectDir, "jui.config.json");
39
+ if (!existsSync(configPath)) {
40
+ throw new Error(`jui.config.json not found in ${projectDir}. Run jui_init first.`);
41
+ }
42
+ return JSON.parse(readFileSync(configPath, "utf-8"));
43
+ }
44
+ resolveDir(config, field, projectDir) {
45
+ const value = config[field];
46
+ if (typeof value !== "string")
47
+ return projectDir;
48
+ if (isAbsolute(value))
49
+ return value;
50
+ return join(projectDir, value);
51
+ }
52
+ validatePathInProject(filePath, projectDir) {
53
+ const resolved = isAbsolute(filePath) ? filePath : join(projectDir, filePath);
54
+ // Realpath both sides so a symlink inside the project cannot smuggle the
55
+ // path outside it (lexical resolve() does not follow symlinks).
56
+ const normalizedProject = realpathLongestExistingPrefix(resolve(projectDir));
57
+ const normalizedPath = realpathLongestExistingPrefix(resolve(resolved));
58
+ // Compare on path-segment boundaries: a bare startsWith() would accept
59
+ // sibling dirs like "<project>-evil".
60
+ const isInside = normalizedPath === normalizedProject ||
61
+ normalizedPath.startsWith(normalizedProject + sep);
62
+ if (!isInside) {
63
+ throw new Error(`Path traversal detected: ${filePath} is outside project directory`);
64
+ }
65
+ return normalizedPath;
66
+ }
67
+ }