jui-tools-mcp-server 2.14.1 → 2.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,7 +70,7 @@ bash ~/.jsonui-mcp-server/uninstall.sh
70
70
  | `list_screen_specs` | 画面 spec 一覧(メタデータ付き) |
71
71
  | `list_component_specs` | コンポーネント spec 一覧 |
72
72
  | `list_layouts` | Layout JSON ファイル一覧 |
73
- | `read_spec_file` | spec ファイルの内容を返す |
73
+ | `read_spec_file` | spec ファイルの内容を返す。散文フィールド(description / notes / intent / purpose / processing / handling / rule / meaning / note / reason〔`harnessConditions`・`canonicalDivergence` の下を除く〕/ condition〔トップレベルの `transitions` の下のみ〕)の `{"md": "key"}`(`<name>.texts.yaml` への参照)は `{"md", "text"}` に展開して返す(編集は YAML 側)。参照の無い spec はファイルそのまま。`search_specs` は texts ファイルの本文を検索しない |
74
74
  | `read_layout_file` | Layout JSON の内容を返す |
75
75
  | `search_specs` | 全 spec(画面・サブディレクトリの sub-spec・コンポーネント)をキーワード検索し、ファイル・JSON path・抜粋を返す |
76
76
 
@@ -697,11 +697,17 @@
697
697
  "string",
698
698
  "array"
699
699
  ],
700
- "description": "Click handler function name (selector-based, lowercase) - string only, no binding"
700
+ "description": "Click handler function name (selector-based, lowercase) - string only, no binding",
701
+ "notApplicableTo": {
702
+ "Web": "the embedded page takes the taps; a tap laid over it would break the page's own taps and scrolling (ruling 2026-10-03: not delivered, the build warns)"
703
+ }
701
704
  },
702
705
  "onClick": {
703
706
  "type": "binding",
704
- "description": "Click handler (camelCase) - binding only (@{functionName}). On a type the SSoT does not declare (an app's own component) JsonUI gives the tap no screen-reader role; the component carries its own."
707
+ "description": "Click handler (camelCase) - binding only (@{functionName}). On a type the SSoT does not declare (an app's own component) JsonUI gives the tap no screen-reader role; the component carries its own.",
708
+ "notApplicableTo": {
709
+ "Web": "the embedded page takes the taps; a tap laid over it would break the page's own taps and scrolling (ruling 2026-10-03: not delivered, the build warns)"
710
+ }
705
711
  },
706
712
  "onLongPress": {
707
713
  "type": "binding",
@@ -714,7 +720,12 @@
714
720
  },
715
721
  "onPan": {
716
722
  "type": "binding",
717
- "description": "Pan gesture handler (camelCase) - binding only (@{functionName}). Invoked repeatedly while the user drags. Payload: cumulative translation since the gesture began (iOS CGSize, Android Offset; web passes the PointerEvent). A handler declared () -> Void is called without the payload."
723
+ "description": "Pan gesture handler (camelCase) - binding only (@{functionName}). Invoked repeatedly while the user drags. Payload: cumulative translation since the gesture began (iOS CGSize, Android Offset; web passes the PointerEvent). A handler declared () -> Void is called without the payload.",
724
+ "notApplicableTo": {
725
+ "TextField": "the field's own drag (text selection, caret placement) takes the gesture; a pan laid over it would break editing (ruling 2026-10-03: not delivered, the build warns)",
726
+ "TextView": "the field's own drag (text selection, caret placement) takes the gesture; a pan laid over it would break editing (ruling 2026-10-03: not delivered, the build warns)",
727
+ "Slider": "the slider's own drag moves its value; a pan laid over it would break that (ruling 2026-10-03: not delivered, the build warns)"
728
+ }
718
729
  },
719
730
  "onPinch": {
720
731
  "type": "binding",
@@ -767,7 +778,7 @@
767
778
  },
768
779
  "include": {
769
780
  "type": "string",
770
- "description": "Include another JSON layout (filename without extension)"
781
+ "description": "Include another JSON layout (its path from the layouts root, without extension). An include is an inline expansion: the included layout is drawn where this node is, and it reads the including layout's data — the parent owns the ViewModel. Under an `id`, the id prefixes the included layout's ids and data names, camelCase-joined (id `side` + `title` -> `sideTitle`), so the included layout's `@{title}` reads the including layout's `sideTitle`; without one it reads `title`. The include node's object maps, `shared_data` and then `data`, are laid over the including layout's data: a key is a name the included layout binds, a value is a literal or a binding read in the including layout's scope. The same on every platform and in Dynamic mode (ruling 2026-10-02). Until jsonui-cli 1.9.6 web handed an include without a map none of the including layout's data (the included layout drew its own defaults), and sjui / kjui codegen ignored an object map."
771
782
  },
772
783
  "variables": {
773
784
  "type": "object",
@@ -777,7 +788,7 @@
777
788
  },
778
789
  "shared_data": {
779
790
  "type": "object",
780
- "description": "Data passed to included components"
791
+ "description": "On an include node: an object map laid over the including layout's data for the included layout, before `data` (`data` wins on a key both set). A key is a name the included layout binds; a value is a literal or a binding read in the including layout's scope (see `include`)."
781
792
  },
782
793
  "style": {
783
794
  "type": "string",
@@ -981,7 +992,7 @@
981
992
  },
982
993
  "data": {
983
994
  "type": "array",
984
- "description": "Data model definitions for binding"
995
+ "description": "Data model definitions for binding: an array of declarations ({name, class, defaultValue}). On an include node an array is declarations merged into the included layout's, and an object is a map laid over the including layout's data for the included layout, after `shared_data` — `data` wins on a key both set (see `include`)."
985
996
  },
986
997
  "bindingScript": {
987
998
  "type": "string",
@@ -1233,7 +1244,7 @@
1233
1244
  "string",
1234
1245
  "binding"
1235
1246
  ],
1236
- "description": "Text content (can be data binding, supports interpolation). PLAIN TEXT: markdown is not interpreted, so \"[label](/path)\", \"**bold**\" and backticks render literally, brackets and all. To emphasise or link part of the string, use partialAttributes ('range' plus font/color/underline, and 'onClick' for a tappable span). Rendering real markdown is out of scope for Label — write a custom component."
1247
+ "description": "Text content: a literal, or one data binding (`@{text}`). A value that mixes literal text with a binding (`Title: @{x}`) is a build warning, binding-mixed-text — compose the string in the ViewModel and bind it as one value (until jsonui-cli 1.9.6 this read \"supports interpolation\"). PLAIN TEXT: markdown is not interpreted, so \"[label](/path)\", \"**bold**\" and backticks render literally, brackets and all. To emphasise or link part of the string, use partialAttributes ('range' plus font/color/underline, and 'onClick' for a tappable span). Rendering real markdown is out of scope for Label — write a custom component."
1237
1248
  },
1238
1249
  "hint": {
1239
1250
  "type": "string",
@@ -2380,7 +2391,7 @@
2380
2391
  "string",
2381
2392
  "binding"
2382
2393
  ],
2383
- "description": "Button text (can be data binding, supports interpolation)"
2394
+ "description": "Button text: a literal, or one data binding (`@{title}`). A value that mixes literal text with a binding is a build warning, binding-mixed-text — compose the string in the ViewModel and bind it as one value (until jsonui-cli 1.9.6 this read \"supports interpolation\")."
2384
2395
  },
2385
2396
  "font": {
2386
2397
  "type": "string",
@@ -2617,7 +2628,13 @@
2617
2628
  "valueAliases": {
2618
2629
  "ScaleToFill": "fill"
2619
2630
  },
2620
- "description": "Content mode (binding supported). ScaleToFill is a declared synonym of fill (the stretch — see attribute_semantics.json#semantics.image); the normalizer folds it, fixtures are generated for the canonical spelling only."
2631
+ "description": "Content mode (binding supported). ScaleToFill is a declared synonym of fill (the stretch — see attribute_semantics.json#semantics.image); the normalizer folds it, fixtures are generated for the canonical spelling only.",
2632
+ "bindingInfo": {
2633
+ "platform": [
2634
+ "swift"
2635
+ ],
2636
+ "note": "a contentMode bound to a value that is AspectFill at run time is cropped to its frame by the generated code only when width and height are numbers; on a frame that is not two numbers the image can be drawn past the frame (jsonui-cli ticket sjui-aspectfill-image-is-not-cropped-to-its-frame, known gap). A literal contentMode is cropped on every frame."
2637
+ }
2621
2638
  },
2622
2639
  "renderingMode": {
2623
2640
  "type": "string",
@@ -2753,7 +2770,13 @@
2753
2770
  "AspectFit",
2754
2771
  "Center"
2755
2772
  ],
2756
- "description": "Content mode (binding supported)"
2773
+ "description": "Content mode (binding supported)",
2774
+ "bindingInfo": {
2775
+ "platform": [
2776
+ "swift"
2777
+ ],
2778
+ "note": "a contentMode bound to a value that is AspectFill at run time is cropped to its frame by the generated code only when width and height are numbers; on a frame that is not two numbers the image can be drawn past the frame (jsonui-cli ticket sjui-aspectfill-image-is-not-cropped-to-its-frame, known gap). A literal contentMode is cropped on every frame."
2779
+ }
2757
2780
  },
2758
2781
  "cachePolicy": {
2759
2782
  "type": "string",
@@ -3094,11 +3117,10 @@
3094
3117
  },
3095
3118
  "onValueChange": {
3096
3119
  "type": "binding",
3097
- "description": "Value change handler - binding only (@{functionName}). The handler receives the new value of the selection binding, the same on every platform: with selectedIndex bound it is the Int index (declare ((Int) -> Void)?, or ((String, Int) -> Void)? for viewId + index); with selectedItem / selectedValue bound it is the String item (declare ((String, String) -> Void)? for viewId + item). A lone (String) parameter is the selected item as a String, with selectedIndex bound too; the viewId comes only as the first of two parameters. (() -> Void)? receives nothing and the ViewModel reads the bound value back."
3098
- },
3099
- "onValueChanged": {
3100
- "type": "binding",
3101
- "description": "Value change handler (alias for onValueChange) - binding only (@{functionName})"
3120
+ "description": "Value change handler - binding only (@{functionName}). The handler receives the new value of the selection binding, the same on every platform: with selectedIndex bound it is the Int index (declare ((Int) -> Void)?, or ((String, Int) -> Void)? for viewId + index); with selectedItem / selectedValue bound it is the String item (declare ((String, String) -> Void)? for viewId + item). A lone (String) parameter is the selected item as a String, with selectedIndex bound too; the viewId comes only as the first of two parameters. (() -> Void)? receives nothing and the ViewModel reads the bound value back. `onValueChanged` folds here, as on Slider, Collection and CheckBox; it was declared as its own attribute, so the normalizer never folded it and sjui codegen called nothing for it (ticket sjui-selectbox-onvaluechanged-alias-is-never-called).",
3121
+ "aliases": [
3122
+ "onValueChanged"
3123
+ ]
3102
3124
  },
3103
3125
  "colorScheme": {
3104
3126
  "type": "string",
@@ -4089,7 +4111,7 @@
4089
4111
  "binding"
4090
4112
  ],
4091
4113
  "binding_direction": "one-way",
4092
- "description": "Collection page/selection change handler. Canonical; prefer over onPageChanged.",
4114
+ "description": "Collection page/selection change handler. Canonical; prefer over onPageChanged. Called when the page changes — by a swipe, a scrollTo or a currentPage write — with the new page; not called when the pager first appears (ruling 2026-10-02).",
4093
4115
  "platform": [
4094
4116
  "swift",
4095
4117
  "kotlin",
@@ -4836,7 +4858,7 @@
4836
4858
  "binding"
4837
4859
  ],
4838
4860
  "binding_direction": "one-way",
4839
- "description": "Tab/page selection change handler. Canonical; prefer over onTabChange/onPageChanged.",
4861
+ "description": "Tab/page selection change handler. Canonical; prefer over onTabChange/onPageChanged. Called when the selected tab changes — by a tap on another tab or a selectedIndex write — with the new index; not called when the TabView first appears, nor when the selected tab is tapped again (ruling 2026-10-02).",
4840
4862
  "platform": [
4841
4863
  "swift",
4842
4864
  "kotlin",
@@ -10,8 +10,8 @@
10
10
  },
11
11
  "contexts": {
12
12
  "text": {
13
- "description": "Mixed-text interpolation. A string attribute may contain any number of '@{...}' occurrences with surrounding literal text; each occurrence is replaced by the stringified resolved value.",
14
- "interpolation": true,
13
+ "description": "A text attribute's binding: the whole value is one '@{...}' expression, stringified. A value that mixes literal text with a binding ('Title: @{x}', '@{a} / @{b}') is a warning, binding-mixed-text (ruling 2026-10-02): compose the string in the ViewModel and bind it as one value — a layout holds no logic, and a composed string cannot be localized as one text. Until jsonui-cli 1.9.6 this context declared mixed-text interpolation (each occurrence replaced by its stringified value); sjui and rjui still interpolate such a value and kjui draws its first binding alone, outside any guarantee.",
14
+ "interpolation": false,
15
15
  "features": ["path", "default"],
16
16
  "unresolved": "emptyString"
17
17
  },
@@ -28,14 +28,14 @@
28
28
  "unresolved": "dropKey"
29
29
  },
30
30
  "nameRef": {
31
- "readBy": "Hand-mirrored per face, measured 2026-09-10 and pinned per face: sjui_tools/lib/swiftui/views/base_view_converter.rb (onClick = binding, onclick = bare selector name) pinned by sjui_tools/spec/swiftui/views/handler_name_spelling_spec.rb; kjui_tools/lib/compose/helpers/modifier_builder.rb (camelCase events require the binding form) pinned by kjui_tools/spec/compose/components/handler_name_spelling_spec.rb; rjui_tools/lib/react/converters/base_converter.rb (onClick = binding only, onclick = bare only) pinned by rjui_tools/spec/react/converters/handler_name_spelling_spec.rb. 0 vectors: this context is code generation, not runtime resolution. Known gap, declared here and not a rule: a bare camelCase handler compiles to a no-op with no build-time report (an inline ERROR comment on kjui and rjui, nothing on sjui). A validator rule would need scoping to the declared event attributes first — consumer layouts also use camelCase keys as custom-component props (measured 2026-09-10).",
32
- "description": "Event / handler NAME references. The camelCase event attributes (onClick, onAppear, onLongPress, …) take the binding form only: '@{name}' names the handler, and a bare string is NOT a handler on any face (measured 2026-09-10: kjui and rjui emit an inline ERROR comment and no call; sjui emits an empty action). The lowercase `onclick` is the UIKit selector legacy and takes the bare name only. Name extraction, not value resolution: path, default and negation are not applicable. Until 2026-09-10 this entry said the two spellings were equivalent; no generator ever implemented that, and the sentence was corrected to what they do.",
31
+ "readBy": "Hand-mirrored per face and pinned per face: sjui_tools/lib/swiftui/views/base_view_converter.rb (onClick = binding, onclick = bare selector name; handler_reference? = binding or bare name for the string-declared events) pinned by sjui_tools/spec/swiftui/views/handler_name_spelling_spec.rb and sjui_tools/spec/swiftui/bare_handler_on_string_events_spec.rb; kjui_tools/lib/compose/helpers/modifier_builder.rb pinned by kjui_tools/spec/compose/components/handler_name_spelling_spec.rb; rjui_tools/lib/react/converters/base_converter.rb (onClick = binding only, onclick = bare only) pinned by rjui_tools/spec/react/converters/handler_name_spelling_spec.rb. The binding-only rule is shared/core/binding_validator_core.rb check_bare_event, pinned by each face's spec/core/binding_bare_event_spec.rb, scoped to the types the SSoT declares (consumer layouts use camelCase keys as custom-component props). 0 vectors: this context is code generation, not runtime resolution. Measured 2026-10-02 over 48 event attributes: a bare name on a string-declared event was still dropped by kjui and rjui on a Collection's onValueChange / onItemAppear and TabView onValueChange (sjui calls it from jsonui-cli 1.9.6) — ticket bare-event-handler-is-dropped-without-a-warning.",
32
+ "description": "Event / handler NAME references. What a camelCase event attribute takes is its declared type (attribute_definitions): declared binding-only (type \"binding\" with no \"string\": onClick, onLongPress, Switch / Slider / Segment / CheckBox / Radio / SelectBox onValueChange, …) it takes '@{name}' only, and a bare string is not a handler — the binding validator reports one (binding-bare-event); declared with \"string\" (onAppear, TextField / TextView onTextChange, TextField onSubmit, a Collection's onValueChange / onItemAppear, TabView onValueChange, …) it takes '@{name}' or the bare name, both naming the data's closure. The lowercase `onclick` is the UIKit selector legacy and takes the bare name only. Name extraction, not value resolution: path, default and negation are not applicable. Until 2026-09-10 this entry said the two spellings were equivalent everywhere, and from then until 2026-10-02 that every camelCase event took the binding form only; neither matched the declarations (the conformance generator writes onAppear's bare name), and the sentence follows the declared types since.",
33
33
  "interpolation": false,
34
34
  "features": [],
35
35
  "unresolved": "platformEventContract"
36
36
  },
37
37
  "twoWay": {
38
- "description": "Two-way bindings (TextField text, Switch isOn, ...). The expression must be a single flat identifier: no dots, no brackets, no '??', no '!'. This keeps write-back and change-handler wiring trivially derivable on every platform.",
38
+ "description": "Two-way bindings (TextField text, Switch isOn, ...). The whole value must be one binding of a single flat identifier: no literal text around it, no second binding, and no dots, no brackets, no '??', no '!' in the expression. This keeps write-back and change-handler wiring trivially derivable on every platform.",
39
39
  "interpolation": false,
40
40
  "features": [],
41
41
  "unresolved": "attributeDefault"
@@ -118,10 +118,12 @@
118
118
  },
119
119
  "validatorRules": [
120
120
  {"id": "binding-double-default", "severity": "error", "context": "any", "rule": "More than one '??' in a single binding expression."},
121
- {"id": "binding-two-way-complex", "severity": "error", "context": "twoWay", "rule": "Two-way binding expression is not a single flat identifier (contains '.', '[', '??', or '!')."},
121
+ {"id": "binding-two-way-complex", "severity": "error", "context": "twoWay", "rule": "A two-way value is not one flat binding: the whole value must be a single flat identifier binding — no literal text mixed with it, no second binding, and no '.', '[', '??', or '!' in the expression."},
122
122
  {"id": "binding-negation-context", "severity": "error", "context": "text|embedParams|nameRef|twoWay", "rule": "'!' negation used outside a boolean value context."},
123
123
  {"id": "binding-default-in-params", "severity": "error", "context": "embedParams", "rule": "'??' default used in an embed params leaf (defaults belong to the embedded screen's data section)."},
124
124
  {"id": "binding-params-array", "severity": "error", "context": "embedParams", "rule": "Array anywhere inside params (pre-existing rule from the embed isolated+params track; restated here)."},
125
- {"id": "binding-cell-parent-scope", "severity": "warning", "context": "collectionCell", "rule": "Cell layout binds a key that is only present in the parent screen's data."}
125
+ {"id": "binding-cell-parent-scope", "severity": "warning", "context": "collectionCell", "rule": "Cell layout binds a key that is only present in the parent screen's data."},
126
+ {"id": "binding-mixed-text", "severity": "warning", "context": "text|value", "rule": "A value of an attribute the component declares holds a binding and is not one binding (literal text around it, or two bindings). Compose the string in the ViewModel (it also keeps the text localizable) and bind it as one value; a layout holds no logic."},
127
+ {"id": "binding-bare-event", "severity": "warning", "context": "nameRef", "rule": "A bare name given to an event attribute of a declared type whose declared type is binding-only (\"binding\" with no \"string\")."}
126
128
  ]
127
129
  }
@@ -43,7 +43,7 @@
43
43
  }
44
44
  },
45
45
  "rules": [
46
- "text attribute supports interpolation with @{binding} syntax",
46
+ "text is a literal or one binding (@{name}); a value that mixes literal text with a binding is the binding-mixed-text warning",
47
47
  "partialAttributes allows styling ranges within text",
48
48
  "linkable enables auto-detection of URLs",
49
49
  "Frame alignment must be .leading for textAlign to work correctly (Swift)"
@@ -0,0 +1,18 @@
1
+ export declare const TEXT_KEYS: readonly string[];
2
+ export declare const TEXT_KEY_SCOPE: Readonly<Record<string, ["not-under" | "only-under", readonly string[]]>>;
3
+ /** Whether `key`, reached through `ancestors` (the dict keys above it, list
4
+ * indices left out), is a prose field a reference may stand in. */
5
+ export declare function isTextField(ancestors: readonly string[], key: string): boolean;
6
+ export interface TextsExpansion {
7
+ /** The spec with each reference expanded, or the input when it has none. */
8
+ data: unknown;
9
+ /** How many references were found / expanded. */
10
+ found: number;
11
+ expanded: number;
12
+ /** One line per reference that could not be expanded. */
13
+ problems: string[];
14
+ /** Texts files read (relative to the spec's directory). */
15
+ files: string[];
16
+ }
17
+ export declare function pairedTextsName(specFile: string): string;
18
+ export declare function expandSpecTexts(data: unknown, specPath: string): TextsExpansion;
@@ -0,0 +1,145 @@
1
+ // Expands `{"md": ...}` prose references in a spec for a reader.
2
+ //
3
+ // A spec's prose fields (TEXT_KEYS below, scoped by TEXT_KEY_SCOPE) may
4
+ // point into a YAML texts file instead of holding the text inline (jsonui-cli
5
+ // shared/core/spec_texts.py is the definition, and `isTextField` mirrors its
6
+ // `is_text_field`):
7
+ //
8
+ // {"md": "a.b.c"} -> <spec name>.texts.yaml, key path a.b.c
9
+ // {"md": "shared/x.texts.yaml#a.b"} -> that file (relative to the spec)
10
+ //
11
+ // This is a READER, not the validator. It expands what it can and marks what
12
+ // it cannot; the YAML rules (string keys, no '.', no duplicates, ...) are
13
+ // enforced by `jsonui-doc validate spec`, and are deliberately not copied
14
+ // here — a second copy of a rule is the drift this codebase keeps removing.
15
+ //
16
+ // Each reference becomes `{"md": <as written>, "text": <the text>}` rather
17
+ // than the bare text: an agent reading the spec must see both what it says
18
+ // and WHERE it lives, or it edits the JSON and the YAML stays stale. The
19
+ // `text` key is not valid in a spec, so a copy pasted back is refused by the
20
+ // validator instead of silently kept.
21
+ import { existsSync, readFileSync } from "fs";
22
+ import { basename, dirname, join } from "path";
23
+ import { parse } from "yaml";
24
+ // Mirrors TEXT_KEYS / TEXT_KEY_SCOPE / is_text_field in jsonui-cli
25
+ // shared/core/spec_texts.py. Prose only: a key a tool parses, matches as a
26
+ // name or emits as code is not here, and at the paths TEXT_KEY_SCOPE names
27
+ // the same key is not prose either.
28
+ export const TEXT_KEYS = [
29
+ "description", "notes", "intent", "purpose", "processing",
30
+ "handling", "rule", "meaning", "note", "reason", "condition",
31
+ ];
32
+ // key -> [mode, ancestor keys]. "not-under": prose unless one of those keys
33
+ // is an ancestor; "only-under": prose only when the first (top-level)
34
+ // ancestor is one of them.
35
+ export const TEXT_KEY_SCOPE = {
36
+ reason: ["not-under", ["harnessConditions", "canonicalDivergence"]],
37
+ condition: ["only-under", ["transitions"]],
38
+ };
39
+ /** Whether `key`, reached through `ancestors` (the dict keys above it, list
40
+ * indices left out), is a prose field a reference may stand in. */
41
+ export function isTextField(ancestors, key) {
42
+ if (!TEXT_KEYS.includes(key))
43
+ return false;
44
+ const scope = Object.prototype.hasOwnProperty.call(TEXT_KEY_SCOPE, key)
45
+ ? TEXT_KEY_SCOPE[key]
46
+ : undefined;
47
+ if (scope === undefined)
48
+ return true;
49
+ const [mode, names] = scope;
50
+ if (mode === "not-under")
51
+ return !ancestors.some((a) => names.includes(a));
52
+ return ancestors.length > 0 && names.includes(ancestors[0]);
53
+ }
54
+ const REF_KEY = "md";
55
+ export function pairedTextsName(specFile) {
56
+ let name = basename(specFile);
57
+ for (const suffix of [".spec.json", ".component.json", ".json"]) {
58
+ if (name.endsWith(suffix)) {
59
+ name = name.slice(0, -suffix.length);
60
+ break;
61
+ }
62
+ }
63
+ return `${name}.texts.yaml`;
64
+ }
65
+ function isReference(value) {
66
+ return (typeof value === "object" && value !== null && !Array.isArray(value) &&
67
+ REF_KEY in value);
68
+ }
69
+ export function expandSpecTexts(data, specPath) {
70
+ const baseDir = dirname(specPath);
71
+ const paired = pairedTextsName(specPath);
72
+ const cache = new Map();
73
+ const result = { data, found: 0, expanded: 0, problems: [], files: [] };
74
+ const load = (rel) => {
75
+ if (!cache.has(rel)) {
76
+ const abs = join(baseDir, rel);
77
+ if (!existsSync(abs)) {
78
+ cache.set(rel, new Error(`${rel} does not exist`));
79
+ }
80
+ else {
81
+ try {
82
+ cache.set(rel, parse(readFileSync(abs, "utf-8")));
83
+ result.files.push(rel);
84
+ }
85
+ catch (e) {
86
+ cache.set(rel, new Error(`${rel} could not be parsed: ${e.message}`));
87
+ }
88
+ }
89
+ }
90
+ return cache.get(rel);
91
+ };
92
+ const expand = (ref, where) => {
93
+ result.found += 1;
94
+ const target = ref[REF_KEY];
95
+ if (typeof target !== "string" || !target.trim()) {
96
+ result.problems.push(`${where}: malformed reference`);
97
+ return ref;
98
+ }
99
+ const hash = target.indexOf("#");
100
+ const file = hash >= 0 ? target.slice(0, hash) : paired;
101
+ const key = hash >= 0 ? target.slice(hash + 1) : target;
102
+ const doc = load(file);
103
+ if (doc instanceof Error) {
104
+ result.problems.push(`${where}: ${doc.message}`);
105
+ return ref;
106
+ }
107
+ let node = doc;
108
+ for (const part of key.split(".")) {
109
+ node = typeof node === "object" && node !== null && !Array.isArray(node)
110
+ ? node[part]
111
+ : undefined;
112
+ }
113
+ if (typeof node !== "string") {
114
+ result.problems.push(`${where}: '${file}#${key}' is ${node === undefined ? "not defined" : "not a text"}`);
115
+ return ref;
116
+ }
117
+ result.expanded += 1;
118
+ return { ...ref, text: node };
119
+ };
120
+ const walk = (node, path, inText, ancestors) => {
121
+ if (Array.isArray(node)) {
122
+ // `notes: [...]` — each entry is prose too. Deeper lists under a text
123
+ // key are not prose, so the flag is not carried past one.
124
+ return node.map((v, i) => walk(v, `${path}[${i}]`, inText && !Array.isArray(v), ancestors));
125
+ }
126
+ if (typeof node === "object" && node !== null) {
127
+ if (isReference(node)) {
128
+ if (inText)
129
+ return expand(node, path);
130
+ // Outside a prose field the validator refuses it; a reader leaves it.
131
+ return node;
132
+ }
133
+ const out = {};
134
+ for (const [k, v] of Object.entries(node)) {
135
+ out[k] = walk(v, path ? `${path}.${k}` : k, isTextField(ancestors, k), [...ancestors, k]);
136
+ }
137
+ return out;
138
+ }
139
+ return node;
140
+ };
141
+ const walked = walk(data, "", false, []);
142
+ if (result.found > 0)
143
+ result.data = walked;
144
+ return result;
145
+ }
@@ -1,8 +1,18 @@
1
1
  import { z } from "zod";
2
2
  import { readFileSync, existsSync } from "fs";
3
3
  import { join } from "path";
4
+ import { expandSpecTexts } from "../../spec_texts.js";
4
5
  export function register(server, config) {
5
- server.tool("read_spec_file", "Read the contents of a screen or component spec file", {
6
+ server.tool("read_spec_file", "Read the contents of a screen or component spec file. Prose fields " +
7
+ "(description / notes / intent / purpose / processing / handling / " +
8
+ "rule / meaning / note / reason — not under harnessConditions or " +
9
+ "canonicalDivergence — and condition under transitions only) written " +
10
+ "as {\"md\": \"key\"} live in a " +
11
+ "YAML texts file (<name>.texts.yaml beside the spec, or " +
12
+ "{\"md\": \"file.texts.yaml#key\"}); they are returned expanded as " +
13
+ "{\"md\": ..., \"text\": ...} — edit the text in the YAML file, not " +
14
+ "here. A spec without such references is returned byte-for-byte. " +
15
+ "search_specs does not search text held in a texts file.", {
6
16
  file: z.string().describe("Filename (e.g., 'login.spec.json' or 'my_card.component.json')"),
7
17
  project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
8
18
  }, async ({ file, project_dir }) => {
@@ -18,7 +28,29 @@ export function register(server, config) {
18
28
  return { content: [{ type: "text", text: `File not found: ${filePath}` }] };
19
29
  }
20
30
  const content = readFileSync(filePath, "utf-8");
21
- return { content: [{ type: "text", text: content }] };
31
+ // Only a spec that uses texts references is re-serialised; every
32
+ // other spec comes back exactly as it is on disk.
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(content);
36
+ }
37
+ catch {
38
+ return { content: [{ type: "text", text: content }] };
39
+ }
40
+ const texts = expandSpecTexts(parsed, filePath);
41
+ if (texts.found === 0) {
42
+ return { content: [{ type: "text", text: content }] };
43
+ }
44
+ const blocks = [{ type: "text", text: JSON.stringify(texts.data, null, 2) }];
45
+ const summary = `texts: ${texts.expanded}/${texts.found} reference(s) expanded as ` +
46
+ `{"md", "text"} from ${texts.files.join(", ") || "(no file read)"} — ` +
47
+ `the "text" key is for reading only; edit the YAML file.`;
48
+ const problems = texts.problems.length
49
+ ? "\nunresolved (run jsonui-doc validate spec for the full check):\n" +
50
+ texts.problems.map((p) => ` - ${p}`).join("\n")
51
+ : "";
52
+ blocks.push({ type: "text", text: summary + problems });
53
+ return { content: blocks };
22
54
  }
23
55
  catch (e) {
24
56
  return { content: [{ type: "text", text: `Error: ${e.message}` }] };
package/docs/design.md CHANGED
@@ -100,7 +100,7 @@ These are served directly by the `get_modifier_order`,
100
100
  | `list_screen_specs` | Screen-spec index with metadata. |
101
101
  | `list_component_specs` | Component-spec index with metadata. |
102
102
  | `list_layouts` | Layout JSON inventory. |
103
- | `read_spec_file` | One spec file (no parsing beyond `JSON.parse`). |
103
+ | `read_spec_file` | One spec file. Byte-for-byte unless it carries `{"md": ...}` prose references (jsonui-cli `shared/core/spec_texts.py`); those are expanded to `{"md", "text"}` from the YAML texts file by `src/spec_texts.ts` — a reader only, the YAML rules stay in `jsonui-doc validate`. Which fields are prose is path-aware and mirrors `is_text_field` (description / notes / intent / purpose / processing / handling / rule / meaning / note / reason — not under `harnessConditions` / `canonicalDivergence` — and `condition` under top-level `transitions` only). `search_specs` does not search texts-file text. |
104
104
  | `read_layout_file` | One layout JSON file. |
105
105
 
106
106
  ### Group C — `jui` CLI wrappers (8)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.14.1",
3
+ "version": "2.15.3",
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": {
@@ -15,6 +15,7 @@
15
15
  "type": "module",
16
16
  "dependencies": {
17
17
  "@modelcontextprotocol/sdk": "^1.12.1",
18
+ "yaml": "^2.9.1",
18
19
  "zod": "^3.23.0"
19
20
  },
20
21
  "devDependencies": {
@@ -44,7 +44,7 @@ const DATA_DIR = join(PROJECT_ROOT, "data");
44
44
  // 3. `npm run fetch-definitions` and commit the data/ diff together with
45
45
  // this pin in one commit
46
46
  // JSONUI_CLI_RAW_BASE still overrides the whole base URL for manual testing.
47
- const JSONUI_CLI_TAG = "v1.9.2";
47
+ const JSONUI_CLI_TAG = "v1.9.8";
48
48
  const BASE_URL =
49
49
  process.env.JSONUI_CLI_RAW_BASE ||
50
50
  `https://raw.githubusercontent.com/Tai-Kimura/jsonui-cli/${JSONUI_CLI_TAG}`;
@@ -0,0 +1,167 @@
1
+ // Expands `{"md": ...}` prose references in a spec for a reader.
2
+ //
3
+ // A spec's prose fields (TEXT_KEYS below, scoped by TEXT_KEY_SCOPE) may
4
+ // point into a YAML texts file instead of holding the text inline (jsonui-cli
5
+ // shared/core/spec_texts.py is the definition, and `isTextField` mirrors its
6
+ // `is_text_field`):
7
+ //
8
+ // {"md": "a.b.c"} -> <spec name>.texts.yaml, key path a.b.c
9
+ // {"md": "shared/x.texts.yaml#a.b"} -> that file (relative to the spec)
10
+ //
11
+ // This is a READER, not the validator. It expands what it can and marks what
12
+ // it cannot; the YAML rules (string keys, no '.', no duplicates, ...) are
13
+ // enforced by `jsonui-doc validate spec`, and are deliberately not copied
14
+ // here — a second copy of a rule is the drift this codebase keeps removing.
15
+ //
16
+ // Each reference becomes `{"md": <as written>, "text": <the text>}` rather
17
+ // than the bare text: an agent reading the spec must see both what it says
18
+ // and WHERE it lives, or it edits the JSON and the YAML stays stale. The
19
+ // `text` key is not valid in a spec, so a copy pasted back is refused by the
20
+ // validator instead of silently kept.
21
+
22
+ import { existsSync, readFileSync } from "fs";
23
+ import { basename, dirname, join } from "path";
24
+ import { parse } from "yaml";
25
+
26
+ // Mirrors TEXT_KEYS / TEXT_KEY_SCOPE / is_text_field in jsonui-cli
27
+ // shared/core/spec_texts.py. Prose only: a key a tool parses, matches as a
28
+ // name or emits as code is not here, and at the paths TEXT_KEY_SCOPE names
29
+ // the same key is not prose either.
30
+ export const TEXT_KEYS: readonly string[] = [
31
+ "description", "notes", "intent", "purpose", "processing",
32
+ "handling", "rule", "meaning", "note", "reason", "condition",
33
+ ];
34
+
35
+ // key -> [mode, ancestor keys]. "not-under": prose unless one of those keys
36
+ // is an ancestor; "only-under": prose only when the first (top-level)
37
+ // ancestor is one of them.
38
+ export const TEXT_KEY_SCOPE: Readonly<Record<string, ["not-under" | "only-under", readonly string[]]>> = {
39
+ reason: ["not-under", ["harnessConditions", "canonicalDivergence"]],
40
+ condition: ["only-under", ["transitions"]],
41
+ };
42
+
43
+ /** Whether `key`, reached through `ancestors` (the dict keys above it, list
44
+ * indices left out), is a prose field a reference may stand in. */
45
+ export function isTextField(ancestors: readonly string[], key: string): boolean {
46
+ if (!TEXT_KEYS.includes(key)) return false;
47
+ const scope = Object.prototype.hasOwnProperty.call(TEXT_KEY_SCOPE, key)
48
+ ? TEXT_KEY_SCOPE[key]
49
+ : undefined;
50
+ if (scope === undefined) return true;
51
+ const [mode, names] = scope;
52
+ if (mode === "not-under") return !ancestors.some((a) => names.includes(a));
53
+ return ancestors.length > 0 && names.includes(ancestors[0]);
54
+ }
55
+ const REF_KEY = "md";
56
+
57
+ export interface TextsExpansion {
58
+ /** The spec with each reference expanded, or the input when it has none. */
59
+ data: unknown;
60
+ /** How many references were found / expanded. */
61
+ found: number;
62
+ expanded: number;
63
+ /** One line per reference that could not be expanded. */
64
+ problems: string[];
65
+ /** Texts files read (relative to the spec's directory). */
66
+ files: string[];
67
+ }
68
+
69
+ export function pairedTextsName(specFile: string): string {
70
+ let name = basename(specFile);
71
+ for (const suffix of [".spec.json", ".component.json", ".json"]) {
72
+ if (name.endsWith(suffix)) {
73
+ name = name.slice(0, -suffix.length);
74
+ break;
75
+ }
76
+ }
77
+ return `${name}.texts.yaml`;
78
+ }
79
+
80
+ function isReference(value: unknown): value is Record<string, unknown> {
81
+ return (
82
+ typeof value === "object" && value !== null && !Array.isArray(value) &&
83
+ REF_KEY in (value as object)
84
+ );
85
+ }
86
+
87
+ export function expandSpecTexts(data: unknown, specPath: string): TextsExpansion {
88
+ const baseDir = dirname(specPath);
89
+ const paired = pairedTextsName(specPath);
90
+ const cache = new Map<string, unknown | Error>();
91
+ const result: TextsExpansion = { data, found: 0, expanded: 0, problems: [], files: [] };
92
+
93
+ const load = (rel: string): unknown | Error => {
94
+ if (!cache.has(rel)) {
95
+ const abs = join(baseDir, rel);
96
+ if (!existsSync(abs)) {
97
+ cache.set(rel, new Error(`${rel} does not exist`));
98
+ } else {
99
+ try {
100
+ cache.set(rel, parse(readFileSync(abs, "utf-8")));
101
+ result.files.push(rel);
102
+ } catch (e: any) {
103
+ cache.set(rel, new Error(`${rel} could not be parsed: ${e.message}`));
104
+ }
105
+ }
106
+ }
107
+ return cache.get(rel);
108
+ };
109
+
110
+ const expand = (ref: Record<string, unknown>, where: string): unknown => {
111
+ result.found += 1;
112
+ const target = ref[REF_KEY];
113
+ if (typeof target !== "string" || !target.trim()) {
114
+ result.problems.push(`${where}: malformed reference`);
115
+ return ref;
116
+ }
117
+ const hash = target.indexOf("#");
118
+ const file = hash >= 0 ? target.slice(0, hash) : paired;
119
+ const key = hash >= 0 ? target.slice(hash + 1) : target;
120
+ const doc = load(file);
121
+ if (doc instanceof Error) {
122
+ result.problems.push(`${where}: ${doc.message}`);
123
+ return ref;
124
+ }
125
+ let node: unknown = doc;
126
+ for (const part of key.split(".")) {
127
+ node = typeof node === "object" && node !== null && !Array.isArray(node)
128
+ ? (node as Record<string, unknown>)[part]
129
+ : undefined;
130
+ }
131
+ if (typeof node !== "string") {
132
+ result.problems.push(
133
+ `${where}: '${file}#${key}' is ${node === undefined ? "not defined" : "not a text"}`);
134
+ return ref;
135
+ }
136
+ result.expanded += 1;
137
+ return { ...ref, text: node };
138
+ };
139
+
140
+ const walk = (
141
+ node: unknown, path: string, inText: boolean, ancestors: readonly string[],
142
+ ): unknown => {
143
+ if (Array.isArray(node)) {
144
+ // `notes: [...]` — each entry is prose too. Deeper lists under a text
145
+ // key are not prose, so the flag is not carried past one.
146
+ return node.map((v, i) =>
147
+ walk(v, `${path}[${i}]`, inText && !Array.isArray(v), ancestors));
148
+ }
149
+ if (typeof node === "object" && node !== null) {
150
+ if (isReference(node)) {
151
+ if (inText) return expand(node, path);
152
+ // Outside a prose field the validator refuses it; a reader leaves it.
153
+ return node;
154
+ }
155
+ const out: Record<string, unknown> = {};
156
+ for (const [k, v] of Object.entries(node)) {
157
+ out[k] = walk(v, path ? `${path}.${k}` : k, isTextField(ancestors, k), [...ancestors, k]);
158
+ }
159
+ return out;
160
+ }
161
+ return node;
162
+ };
163
+
164
+ const walked = walk(data, "", false, []);
165
+ if (result.found > 0) result.data = walked;
166
+ return result;
167
+ }
@@ -3,11 +3,21 @@ import { z } from "zod";
3
3
  import { readFileSync, existsSync } from "fs";
4
4
  import { join } from "path";
5
5
  import { ServerConfig } from "../../config.js";
6
+ import { expandSpecTexts } from "../../spec_texts.js";
6
7
 
7
8
  export function register(server: McpServer, config: ServerConfig) {
8
9
  server.tool(
9
10
  "read_spec_file",
10
- "Read the contents of a screen or component spec file",
11
+ "Read the contents of a screen or component spec file. Prose fields " +
12
+ "(description / notes / intent / purpose / processing / handling / " +
13
+ "rule / meaning / note / reason — not under harnessConditions or " +
14
+ "canonicalDivergence — and condition under transitions only) written " +
15
+ "as {\"md\": \"key\"} live in a " +
16
+ "YAML texts file (<name>.texts.yaml beside the spec, or " +
17
+ "{\"md\": \"file.texts.yaml#key\"}); they are returned expanded as " +
18
+ "{\"md\": ..., \"text\": ...} — edit the text in the YAML file, not " +
19
+ "here. A spec without such references is returned byte-for-byte. " +
20
+ "search_specs does not search text held in a texts file.",
11
21
  {
12
22
  file: z.string().describe("Filename (e.g., 'login.spec.json' or 'my_card.component.json')"),
13
23
  project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
@@ -29,7 +39,29 @@ export function register(server: McpServer, config: ServerConfig) {
29
39
  }
30
40
 
31
41
  const content = readFileSync(filePath, "utf-8");
32
- return { content: [{ type: "text", text: content }] };
42
+ // Only a spec that uses texts references is re-serialised; every
43
+ // other spec comes back exactly as it is on disk.
44
+ let parsed: unknown;
45
+ try {
46
+ parsed = JSON.parse(content);
47
+ } catch {
48
+ return { content: [{ type: "text", text: content }] };
49
+ }
50
+ const texts = expandSpecTexts(parsed, filePath);
51
+ if (texts.found === 0) {
52
+ return { content: [{ type: "text", text: content }] };
53
+ }
54
+ const blocks = [{ type: "text" as const, text: JSON.stringify(texts.data, null, 2) }];
55
+ const summary =
56
+ `texts: ${texts.expanded}/${texts.found} reference(s) expanded as ` +
57
+ `{"md", "text"} from ${texts.files.join(", ") || "(no file read)"} — ` +
58
+ `the "text" key is for reading only; edit the YAML file.`;
59
+ const problems = texts.problems.length
60
+ ? "\nunresolved (run jsonui-doc validate spec for the full check):\n" +
61
+ texts.problems.map((p) => ` - ${p}`).join("\n")
62
+ : "";
63
+ blocks.push({ type: "text" as const, text: summary + problems });
64
+ return { content: blocks };
33
65
  } catch (e: any) {
34
66
  return { content: [{ type: "text", text: `Error: ${e.message}` }] };
35
67
  }
@@ -2,11 +2,12 @@
2
2
  * Group B tools (project context) — exercised against a tmp-dir fixture
3
3
  * project (jui.config.json + specs + layouts). No real project is touched.
4
4
  */
5
- import { symlinkSync, writeFileSync } from "fs";
5
+ import { mkdirSync, symlinkSync, writeFileSync } from "fs";
6
6
  import { join } from "path";
7
7
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
8
 
9
9
  import { ServerConfig } from "../src/config.js";
10
+ import { isTextField } from "../src/spec_texts.js";
10
11
  import { register as registerGetProjectConfig } from "../src/tools/context/get_project_config.js";
11
12
  import { register as registerListScreenSpecs } from "../src/tools/context/list_screen_specs.js";
12
13
  import { register as registerListComponentSpecs } from "../src/tools/context/list_component_specs.js";
@@ -341,6 +342,115 @@ describe("read_spec_file", () => {
341
342
  });
342
343
  });
343
344
 
345
+ describe("read_spec_file — texts-file prose", () => {
346
+ // `{"md": ...}` in a prose field (description / notes / intent / ...,
347
+ // path-scoped for reason and condition) points into a YAML file
348
+ // (jsonui-cli shared/core/spec_texts.py). The reader expands it next to the
349
+ // reference, so an agent sees the text AND where to edit it.
350
+ const specDir = () => join(projectDir, "docs/screens/json");
351
+ const spec = {
352
+ type: "app_contracts_spec",
353
+ version: "1.0",
354
+ metadata: { name: "app", description: { md: "overview" } },
355
+ unitContracts: [
356
+ {
357
+ target: "ApiClient",
358
+ cases: [
359
+ { name: "retries", intent: { md: "cases.api.retries" } },
360
+ { name: "shared", intent: { md: "shared/net.texts.yaml#timeout" } },
361
+ { name: "inline", intent: "plain" },
362
+ ],
363
+ },
364
+ ],
365
+ };
366
+
367
+ async function callFull(file: string) {
368
+ const tool = harness.tools.get("read_spec_file")!;
369
+ const result = await tool.handler({ file, project_dir: projectDir });
370
+ return result.content.map((c: { text: string }) => c.text);
371
+ }
372
+
373
+ beforeEach(() => {
374
+ writeJson(join(specDir(), "app.spec.json"), spec);
375
+ writeFileSync(
376
+ join(specDir(), "app.texts.yaml"),
377
+ "overview: |\n # Title\n\n - item\ncases:\n api:\n retries: \"**once**\"\n"
378
+ );
379
+ });
380
+
381
+ it("expands paired and named references as {md, text}", async () => {
382
+ const shared = join(specDir(), "shared");
383
+ mkdirSync(shared, { recursive: true });
384
+ writeFileSync(join(shared, "net.texts.yaml"), "timeout: ten seconds\n");
385
+ const [json, summary] = await callFull("app.spec.json");
386
+ const data = JSON.parse(json);
387
+ expect(data.metadata.description).toEqual({ md: "overview", text: "# Title\n\n- item\n" });
388
+ const cases = data.unitContracts[0].cases;
389
+ expect(cases[0].intent).toEqual({ md: "cases.api.retries", text: "**once**" });
390
+ expect(cases[1].intent.text).toBe("ten seconds");
391
+ expect(cases[2].intent).toBe("plain");
392
+ expect(summary).toContain("3/3 reference(s) expanded");
393
+ expect(summary).toContain("edit the YAML file");
394
+ });
395
+
396
+ it("names a reference it cannot expand and leaves it as written", async () => {
397
+ const [json, summary] = await callFull("app.spec.json");
398
+ const cases = JSON.parse(json).unitContracts[0].cases;
399
+ expect(cases[1].intent).toEqual({ md: "shared/net.texts.yaml#timeout" });
400
+ expect(summary).toContain("2/3 reference(s) expanded");
401
+ expect(summary).toContain("shared/net.texts.yaml does not exist");
402
+ });
403
+
404
+ it("expands purpose / reason / condition-under-transitions, and leaves reason and condition where they are not prose", async () => {
405
+ const scoped = {
406
+ type: "screen_spec",
407
+ metadata: { name: "Scoped" },
408
+ decorativeElements: [{ id: "bg", purpose: { md: "p" } }],
409
+ apiOutcomeRules: [{ op: "get", reason: { md: "r" } }],
410
+ transitions: [{ destination: "Next", condition: { md: "c" } }],
411
+ harnessConditions: [{ name: "h", reason: { md: "r" } }],
412
+ contracts: { canonicalDivergence: [{ reason: { md: "r" } }] },
413
+ displayLogic: [{ condition: { md: "c" } }],
414
+ navigation: { transitions: [{ condition: { md: "c" } }] },
415
+ };
416
+ writeJson(join(specDir(), "scoped.spec.json"), scoped);
417
+ writeFileSync(join(specDir(), "scoped.texts.yaml"), "p: the purpose\nr: the reason\nc: the condition\n");
418
+ const [json, summary] = await callFull("scoped.spec.json");
419
+ const data = JSON.parse(json);
420
+ expect(data.decorativeElements[0].purpose).toEqual({ md: "p", text: "the purpose" });
421
+ expect(data.apiOutcomeRules[0].reason).toEqual({ md: "r", text: "the reason" });
422
+ expect(data.transitions[0].condition).toEqual({ md: "c", text: "the condition" });
423
+ // Not prose at these paths: found, left exactly as written.
424
+ expect(data.harnessConditions[0].reason).toEqual({ md: "r" });
425
+ expect(data.contracts.canonicalDivergence[0].reason).toEqual({ md: "r" });
426
+ expect(data.displayLogic[0].condition).toEqual({ md: "c" });
427
+ // `transitions` must be the FIRST ancestor, not any ancestor.
428
+ expect(data.navigation.transitions[0].condition).toEqual({ md: "c" });
429
+ expect(summary).toContain("3/3 reference(s) expanded");
430
+ });
431
+
432
+ it("isTextField matches jsonui-cli is_text_field", () => {
433
+ expect(isTextField([], "description")).toBe(true);
434
+ expect(isTextField(["userActions"], "processing")).toBe(true);
435
+ expect(isTextField(["apiOutcomeRules"], "reason")).toBe(true);
436
+ expect(isTextField(["harnessConditions"], "reason")).toBe(false);
437
+ expect(isTextField(["x", "canonicalDivergence", "y"], "reason")).toBe(false);
438
+ expect(isTextField(["transitions"], "condition")).toBe(true);
439
+ expect(isTextField([], "condition")).toBe(false);
440
+ expect(isTextField(["validation", "serverSide"], "condition")).toBe(false);
441
+ expect(isTextField(["a", "transitions"], "condition")).toBe(false);
442
+ expect(isTextField([], "displayName")).toBe(false);
443
+ expect(isTextField([], "toString")).toBe(false);
444
+ });
445
+
446
+ it("returns a spec without references byte-for-byte", async () => {
447
+ const raw = '{"type":"screen_spec", "metadata":{"name":"X","description":"a\\nb"}}';
448
+ writeFileSync(join(specDir(), "raw.spec.json"), raw);
449
+ const texts = await callFull("raw.spec.json");
450
+ expect(texts).toEqual([raw]);
451
+ });
452
+ });
453
+
344
454
  describe("read_layout_file", () => {
345
455
  it("reads a top-level layout", async () => {
346
456
  const text = await harness.call("read_layout_file", {