Parcourir la source

docs: share one path helper on the smartbotic global instead of copying it per node

fszontagh il y a 1 mois
Parent
commit
d78fbe2669
1 fichiers modifiés avec 240 ajouts et 343 suppressions
  1. 240 343
      docs/superpowers/plans/2026-08-04-tier-1-nodes.md

+ 240 - 343
docs/superpowers/plans/2026-08-04-tier-1-nodes.md

@@ -15,7 +15,7 @@
 - Nodes **throw** on failure; they never return `success: false`.
 - Node config values arrive **pre-evaluated** - `WorkflowEngine::evaluateExpressions` walks the config before `execute()`, and a config string that is exactly one `{{...}}` keeps its native type. Nodes must not re-interpolate.
 - All eleven nodes live in `nodes/core/`.
-- No imports between node files - each node is stored as one blob, so shared helpers are copied per file.
+- No imports between node files - each node is stored as one blob. Anything several nodes need lives on the `smartbotic` global, provided by the C++ script engine, not copied per file.
 - Agreed field-path semantics for every new node: dot paths, `[n]` and bare numeric indices, `.length` on arrays, `undefined` for anything missing.
 - QuickJS here is Bellard's build with **no Intl** - no named timezones.
 - Never write an em-dash or en-dash in any file, commit message or comment.
@@ -514,7 +514,192 @@ git commit -m "feat: the runner understands a node whose ports come from its con
 
 ---
 
-### Task 5: Render derived ports and named inputs
+### Task 5: One path helper for every node
+
+Seven of the eleven nodes need to read a value out of nested data by dotted path. `if-condition.js` and `loop.js` each already carry their own copy with **different** semantics - `loop.js` silently skips a leading `data.` segment, `if-condition.js` does not - which is the sort of divergence that makes a workflow behave differently depending on which node reads the path. Rather than adding seven more copies, the helper goes on the `smartbotic` global once, beside the `utils.pick` and `utils.omit` that are already there.
+
+The two existing nodes are deliberately left alone: changing how `if-condition` or `loop` resolves a path would alter workflows people are already running.
+
+**Files:**
+- Modify: `src/runner/engine/script_engine.cpp:1550` (after the `utils.pick` registration, before `utils.omit`)
+- Create: `tests/nodes/utils-get-field-value.json`
+
+**Interfaces:**
+- Produces: `smartbotic.utils.getFieldValue(data, path)`, consumed by Tasks 9, 10, 12, 13, 14 and 15. Returns `undefined` for anything missing. Supports dotted paths, `key[n]` and bare numeric segments for arrays, and `.length` on an array. An empty or missing path returns `data` unchanged. No leading-`data.` special case - the path is taken literally.
+
+- [ ] **Step 1: Register the helper**
+
+The surrounding registrations are C lambdas passed to `JS_NewCFunction`, each freeing every `JSValue` and `CString` it creates. Match that exactly - QuickJS is refcounted and a missed `JS_FreeValue` is a leak in a long-running runner.
+
+Insert after the closing `}, "pick", 2));` at line 1550, before the `// utils.omit` comment:
+
+```cpp
+    // utils.getFieldValue(data, path) - Read a nested value by dotted path.
+    // Supports a.b.c, arrays via key[0] or a bare numeric segment, and .length
+    // on an array. Returns undefined for anything missing.
+    JS_SetPropertyStr(ctx, utils, "getFieldValue", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
+        if (argc < 1) {
+            return JS_ThrowTypeError(ctx, "utils.getFieldValue requires a value and a path");
+        }
+
+        if (argc < 2 || JS_IsUndefined(argv[1]) || JS_IsNull(argv[1])) {
+            return JS_DupValue(ctx, argv[0]);
+        }
+
+        const char* path_c = JS_ToCString(ctx, argv[1]);
+        if (!path_c) {
+            return JS_EXCEPTION;
+        }
+        std::string path(path_c);
+        JS_FreeCString(ctx, path_c);
+
+        if (path.empty()) {
+            return JS_DupValue(ctx, argv[0]);
+        }
+
+        std::vector<std::string> keys;
+        size_t start = 0;
+        while (true) {
+            size_t dot = path.find('.', start);
+            if (dot == std::string::npos) {
+                keys.push_back(path.substr(start));
+                break;
+            }
+            keys.push_back(path.substr(start, dot - start));
+            start = dot + 1;
+        }
+
+        JSValue current = JS_DupValue(ctx, argv[0]);
+
+        for (const auto& raw_key : keys) {
+            if (JS_IsUndefined(current) || JS_IsNull(current)) {
+                JS_FreeValue(ctx, current);
+                return JS_UNDEFINED;
+            }
+
+            bool is_array = JS_IsArray(ctx, current);
+
+            if (raw_key == "length" && is_array) {
+                JSValue length = JS_GetPropertyStr(ctx, current, "length");
+                JS_FreeValue(ctx, current);
+                current = length;
+                continue;
+            }
+
+            // key[3] - step into the property, then index it
+            std::string key = raw_key;
+            bool has_index = false;
+            int64_t index = 0;
+            size_t bracket = raw_key.find('[');
+            if (bracket != std::string::npos && raw_key.back() == ']') {
+                std::string index_text = raw_key.substr(bracket + 1, raw_key.size() - bracket - 2);
+                if (!index_text.empty() &&
+                    index_text.find_first_not_of("0123456789") == std::string::npos) {
+                    key = raw_key.substr(0, bracket);
+                    index = std::stoll(index_text);
+                    has_index = true;
+                }
+            }
+
+            if (!key.empty()) {
+                bool numeric = key.find_first_not_of("0123456789") == std::string::npos;
+                if (numeric && is_array) {
+                    JSValue element = JS_GetPropertyUint32(ctx, current, (uint32_t)std::stoll(key));
+                    JS_FreeValue(ctx, current);
+                    current = element;
+                } else {
+                    if (!JS_IsObject(current)) {
+                        JS_FreeValue(ctx, current);
+                        return JS_UNDEFINED;
+                    }
+                    JSValue next = JS_GetPropertyStr(ctx, current, key.c_str());
+                    JS_FreeValue(ctx, current);
+                    current = next;
+                }
+            }
+
+            if (has_index) {
+                if (!JS_IsArray(ctx, current)) {
+                    JS_FreeValue(ctx, current);
+                    return JS_UNDEFINED;
+                }
+                JSValue element = JS_GetPropertyUint32(ctx, current, (uint32_t)index);
+                JS_FreeValue(ctx, current);
+                current = element;
+            }
+        }
+
+        return current;
+    }, "getFieldValue", 2));
+```
+
+If `<vector>` or `<string>` is not already included in this file, add it - check the existing includes first, since `utils.omit` already uses `std::vector<std::string>`, so both are almost certainly present.
+
+- [ ] **Step 2: Build and restart**
+
+Run:
+```bash
+cmake --build build -j$(nproc)
+pkill -f smartbotic-webserver; pkill -f smartbotic-runner; sleep 1
+(./build/smartbotic-webserver >/tmp/webserver.log 2>&1 &)
+(./build/smartbotic-runner >/tmp/runner.log 2>&1 &)
+sleep 3
+ss -ltnp | grep -E '8090|9011|9012'
+```
+Expected: compiles clean, all three ports listening again.
+
+- [ ] **Step 3: Write the verification case**
+
+A single `code` node exercising every path form, so a regression in the helper shows up as a concrete wrong value rather than a vague node failure.
+
+```json
+{
+  "name": "verify-utils-get-field-value",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Probe", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "const d = { a: { b: { c: 7 } }, list: [{ id: 'x' }, { id: 'y' }], flat: [10, 20, 30] };\nconst g = smartbotic.utils.getFieldValue;\nreturn {\n  nested: g(d, 'a.b.c'),\n  bracket: g(d, 'list[1].id'),\n  numeric: g(d, 'flat.2'),\n  length: g(d, 'flat.length'),\n  missing: g(d, 'a.nope.c') === undefined,\n  throughNull: g({ a: null }, 'a.b') === undefined,\n  emptyPath: g(d, '').a.b.c,\n  notAnObject: g(d, 'a.b.c.d') === undefined\n};"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
+  ],
+  "expect": {
+    "n2": {"status": "completed", "output": {"result": {
+      "nested": 7,
+      "bracket": "y",
+      "numeric": 30,
+      "length": 3,
+      "missing": true,
+      "throughNull": true,
+      "emptyPath": 7,
+      "notAnObject": true
+    }}}
+  }
+}
+```
+
+- [ ] **Step 4: Run it**
+
+Run: `python3 scripts/verify-node.py tests/nodes/utils-get-field-value.json`
+Expected: `PASS`.
+
+A JSON `null` coming back where `undefined` was expected means the helper returned a QuickJS undefined that got serialized as null - check that the missing-path branches return `JS_UNDEFINED` rather than `JS_NULL`. The case compares against `undefined` inside the script precisely so this distinction is caught in JavaScript, before serialization.
+
+- [ ] **Step 5: Re-run the baseline**
+
+Run: `python3 scripts/verify-node.py tests/nodes/baseline-if-condition.json`
+Expected: `PASS`. `if-condition` keeps its own copy of the helper and must be untouched by this task.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/runner/engine/script_engine.cpp tests/nodes/utils-get-field-value.json
+git commit -m "feat: one path helper on the smartbotic global, for every node to share"
+```
+
+---
+
+### Task 6: Render derived ports and named inputs
 
 Ports are derived at **render time** from `data.dynamicOutputs` and `data.config`, not precomputed. `data.config` already updates when a node's config is saved, so the ports follow a rule being added or removed with no extra wiring.
 
@@ -524,7 +709,7 @@ Ports are derived at **render time** from `data.dynamicOutputs` and `data.config
 
 **Interfaces:**
 - Consumes: `dynamicOutputs` on the REST node payload from Task 3.
-- Produces: `resolveOutputs(staticOutputs, dynamicOutputs, config): NodeOutput[]`, exported from `WorkflowNode.tsx` for reuse in Task 6.
+- Produces: `resolveOutputs(staticOutputs, dynamicOutputs, config): NodeOutput[]`, exported from `WorkflowNode.tsx` for reuse in Task 7.
 
 - [ ] **Step 1: Extend the types**
 
@@ -656,7 +841,7 @@ Add the `inputs` binding beside `outputs` in the component body:
 - [ ] **Step 4: Check it compiles**
 
 Run: `cd webui && npm run lint && npm run build`
-Expected: no TypeScript errors. `resolveOutputs` is exported but not yet used elsewhere - that is fine, it is consumed in Task 6.
+Expected: no TypeScript errors. `resolveOutputs` is exported but not yet used elsewhere - that is fine, it is consumed in Task 7.
 
 - [ ] **Step 5: Commit**
 
@@ -667,7 +852,7 @@ git commit -m "feat(webui): draw ports a node derives from its config, and named
 
 ---
 
-### Task 6: Wire derived ports through the editor
+### Task 7: Wire derived ports through the editor
 
 Three sites build a node's `data`. Each needs to pass `dynamicOutputs` and `inputs` through rather than only the static `outputs`. Saving a config that removes a rule must also drop the edges that pointed at the vanished port.
 
@@ -675,7 +860,7 @@ Three sites build a node's `data`. Each needs to pass `dynamicOutputs` and `inpu
 - Modify: `webui/src/pages/WorkflowEditorPage.tsx:894-918` (view mode), `:968-992` (load), `:1307-1331` (addNode), `:1628-1641` (saveNodeConfig)
 
 **Interfaces:**
-- Consumes: `resolveOutputs` from Task 5.
+- Consumes: `resolveOutputs` from Task 6.
 
 - [ ] **Step 1: Pass the definition through at all three construction sites**
 
@@ -749,7 +934,7 @@ git commit -m "feat(webui): derived ports follow a node's config, and stale edge
 
 ---
 
-### Task 7: Set / Edit Fields
+### Task 8: Set / Edit Fields
 
 The node the roadmap calls the single biggest win. Because the engine pre-evaluates config, this node's whole job is placing already-computed values at the right paths.
 
@@ -944,17 +1129,17 @@ git commit -m "feat: a Set Fields node, so shaping data stops needing a Code nod
 
 ---
 
-### Task 8: Switch
+### Task 9: Switch
 
-The first node with derived ports. Its passing test is the proof that Tasks 2 through 6 work end to end.
+The first node with derived ports. Its passing test is the proof that Tasks 2 through 7 work end to end.
 
 **Files:**
 - Create: `nodes/core/switch.js`
 - Create: `tests/nodes/switch.json`
 
 **Interfaces:**
-- Consumes: the `dynamicOutputs` parsing and rendering from Tasks 2-6.
-- Produces: `getFieldValue(data, path)` - the agreed path helper, copied verbatim into Tasks 9, 11, 12.
+- Consumes: the `dynamicOutputs` parsing and rendering from Tasks 2-4 and 6-7.
+- Consumes: `smartbotic.utils.getFieldValue(data, path)` from Task 5.
 
 - [ ] **Step 1: Write the node**
 
@@ -1031,49 +1216,6 @@ const outputSchema = {
     }
 };
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 function ruleMatches(fieldValue, rule) {
     const compare = rule.value;
 
@@ -1116,7 +1258,7 @@ function ruleMatches(fieldValue, rule) {
 async function execute(config, input, context) {
     const rules = Array.isArray(config.rules) ? config.rules : [];
     const data = input;
-    const fieldValue = getFieldValue(data, config.field);
+    const fieldValue = smartbotic.utils.getFieldValue(data, config.field);
 
     for (let i = 0; i < rules.length; i++) {
         if (ruleMatches(fieldValue, rules[i] || {})) {
@@ -1212,7 +1354,7 @@ git commit -m "feat: a Switch node that grows an output per rule"
 
 ---
 
-### Task 9: Filter
+### Task 10: Filter
 
 Both ports carry data at once. That works because this node emits **no** `_activeBranch` - the engine then reads `output[sourceOutput]` for each named connection independently.
 
@@ -1221,7 +1363,7 @@ Both ports carry data at once. That works because this node emits **no** `_activ
 - Create: `tests/nodes/filter.json`
 
 **Interfaces:**
-- Consumes: `getFieldValue` from Task 8 (copied, not imported).
+- Consumes: `smartbotic.utils.getFieldValue(data, path)` from Task 5.
 
 - [ ] **Step 1: Write the node**
 
@@ -1297,56 +1439,13 @@ const outputSchema = {
     }
 };
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 function isEmpty(value) {
     return value === undefined || value === null || value === '' ||
         (Array.isArray(value) && value.length === 0);
 }
 
 function conditionHolds(item, condition) {
-    const fieldValue = getFieldValue(item, condition.field);
+    const fieldValue = smartbotic.utils.getFieldValue(item, condition.field);
     const compare = condition.value;
 
     switch (condition.operator) {
@@ -1394,7 +1493,7 @@ async function execute(config, input, context) {
     if (Array.isArray(inputField)) {
         items = inputField;
     } else {
-        items = getFieldValue(input, inputField || 'data');
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
     }
 
     if (!Array.isArray(items)) {
@@ -1484,16 +1583,16 @@ git commit -m "feat: a Filter node, with the dropped items kept on their own out
 
 ---
 
-### Task 10: Merge
+### Task 11: Merge
 
-The first node with two input handles. Its passing test proves the `const inputs` work from Tasks 2 and 5.
+The first node with two input handles. Its passing test proves the `const inputs` work from Tasks 2 and 6.
 
 **Files:**
 - Create: `nodes/core/merge.js`
 - Create: `tests/nodes/merge.json`
 
 **Interfaces:**
-- Consumes: `const inputs` parsing from Task 2, handle rendering from Task 5.
+- Consumes: `const inputs` parsing from Task 2, handle rendering from Task 6.
 
 - [ ] **Step 1: Write the node**
 
@@ -1659,7 +1758,7 @@ git commit -m "feat: a Merge node, and two input handles for it to use"
 
 ---
 
-### Task 11: Split Out and Aggregate
+### Task 12: Split Out and Aggregate
 
 A pair, because each is the other's inverse and they are tested against each other.
 
@@ -1668,7 +1767,7 @@ A pair, because each is the other's inverse and they are tested against each oth
 - Create: `tests/nodes/split-aggregate.json`
 
 **Interfaces:**
-- Consumes: `getFieldValue` from Task 8 (copied into both files).
+- Consumes: `smartbotic.utils.getFieldValue(data, path)` from Task 5.
 
 - [ ] **Step 1: Write split-out**
 
@@ -1727,49 +1826,6 @@ const outputSchema = {
     }
 };
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 function setByPath(target, path, value) {
     const keys = String(path).split('.');
     let cursor = target;
@@ -1795,7 +1851,7 @@ async function execute(config, input, context) {
     if (Array.isArray(field)) {
         list = field;
     } else {
-        list = getFieldValue(input, field);
+        list = smartbotic.utils.getFieldValue(input, field);
     }
 
     if (!Array.isArray(list)) {
@@ -1822,7 +1878,7 @@ async function execute(config, input, context) {
         } else if (include === 'selected') {
             for (const path of includeFields) {
                 if (path) {
-                    setByPath(item, path, getFieldValue(parent, path));
+                    setByPath(item, path, smartbotic.utils.getFieldValue(parent, path));
                 }
             }
         }
@@ -1894,49 +1950,6 @@ const outputSchema = {
     }
 };
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 async function execute(config, input, context) {
     const inputField = config.inputField;
     const outputField = config.outputField || 'items';
@@ -1947,7 +1960,7 @@ async function execute(config, input, context) {
     if (Array.isArray(inputField)) {
         items = inputField;
     } else {
-        items = getFieldValue(input, inputField || 'data');
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
     }
 
     if (!Array.isArray(items)) {
@@ -1955,7 +1968,7 @@ async function execute(config, input, context) {
     }
 
     const values = fieldToAggregate
-        ? items.map(function (item) { return getFieldValue(item, fieldToAggregate); })
+        ? items.map(function (item) { return smartbotic.utils.getFieldValue(item, fieldToAggregate); })
         : items;
 
     if (!groupBy) {
@@ -1968,7 +1981,7 @@ async function execute(config, input, context) {
     const groups = {};
     const order = [];
     for (let i = 0; i < items.length; i++) {
-        const key = String(getFieldValue(items[i], groupBy));
+        const key = String(smartbotic.utils.getFieldValue(items[i], groupBy));
         if (groups[key] === undefined) {
             groups[key] = [];
             order.push(key);
@@ -2039,7 +2052,7 @@ git commit -m "feat: Split Out and Aggregate, so Loop is not the only way to res
 
 ---
 
-### Task 12: Sort, Limit and Dedupe
+### Task 13: Sort, Limit and Dedupe
 
 One node. The three are always used together and each alone is ten lines.
 
@@ -2048,7 +2061,7 @@ One node. The three are always used together and each alone is ten lines.
 - Create: `tests/nodes/sort-limit-dedupe.json`
 
 **Interfaces:**
-- Consumes: `getFieldValue` from Task 8 (copied).
+- Consumes: `smartbotic.utils.getFieldValue(data, path)` from Task 5.
 
 - [ ] **Step 1: Write the node**
 
@@ -2131,49 +2144,6 @@ const outputSchema = {
     }
 };
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 function compareValues(left, right, compareAs) {
     if (compareAs === 'number' || (compareAs === 'auto' && typeof left === 'number' && typeof right === 'number')) {
         const a = Number(left);
@@ -2199,7 +2169,7 @@ async function execute(config, input, context) {
     if (Array.isArray(inputField)) {
         items = inputField;
     } else {
-        items = getFieldValue(input, inputField || 'data');
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
     }
 
     if (!Array.isArray(items)) {
@@ -2213,7 +2183,7 @@ async function execute(config, input, context) {
         const seen = {};
         const unique = [];
         for (const item of working) {
-            const key = String(getFieldValue(item, dedupeBy));
+            const key = String(smartbotic.utils.getFieldValue(item, dedupeBy));
             if (seen[key] === true) {
                 removedDuplicates++;
                 continue;
@@ -2229,8 +2199,8 @@ async function execute(config, input, context) {
             for (const rule of sortBy) {
                 if (!rule || !rule.field) continue;
                 const order = compareValues(
-                    getFieldValue(left, rule.field),
-                    getFieldValue(right, rule.field),
+                    smartbotic.utils.getFieldValue(left, rule.field),
+                    smartbotic.utils.getFieldValue(right, rule.field),
                     rule.type || 'auto'
                 );
                 if (order !== 0) {
@@ -2306,14 +2276,14 @@ git commit -m "feat: sorting, top-N and dedupe in one node"
 
 ---
 
-### Task 13: Template and JSON
+### Task 14: Template and JSON
 
 **Files:**
 - Create: `nodes/core/template.js`, `nodes/core/json.js`
 - Create: `tests/nodes/template-json.json`
 
 **Interfaces:**
-- Consumes: `getFieldValue` from Task 8 (copied into `json.js`).
+- Consumes: `smartbotic.utils.getFieldValue(data, path)` from Task 5.
 
 - [ ] **Step 1: Write template**
 
@@ -2450,55 +2420,12 @@ const outputSchema = {
     }
 };
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 async function execute(config, input, context) {
     const operation = config.operation || 'parse';
     const outputField = config.outputField || 'value';
     const onError = config.onError || 'throw';
 
-    const source = getFieldValue(input, config.inputField || 'data');
+    const source = smartbotic.utils.getFieldValue(input, config.inputField || 'data');
     const result = {};
 
     if (operation === 'stringify') {
@@ -2509,7 +2436,7 @@ async function execute(config, input, context) {
     }
 
     if (operation === 'extract') {
-        result[outputField] = getFieldValue(source, config.path);
+        result[outputField] = smartbotic.utils.getFieldValue(source, config.path);
         return result;
     }
 
@@ -2588,7 +2515,7 @@ git commit -m "feat: Template and JSON nodes"
 
 ---
 
-### Task 14: Date and Time
+### Task 15: Date and Time
 
 Offsets only. QuickJS here has no `Intl`, so `Europe/Budapest` cannot be resolved and the node says so in its description rather than pretending.
 
@@ -2684,49 +2611,6 @@ const UNIT_MS = {
     weeks: 604800000
 };
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 function toTimestamp(value, label) {
     if (value === undefined || value === null || value === '') {
         throw new Error('Date and Time: no date found at "' + label + '"');
@@ -2804,7 +2688,7 @@ async function execute(config, input, context) {
         return result;
     }
 
-    const raw = getFieldValue(input, config.inputField || 'data');
+    const raw = smartbotic.utils.getFieldValue(input, config.inputField || 'data');
     const timestamp = toTimestamp(raw, config.inputField || 'data');
 
     if (operation === 'parse') {
@@ -2821,7 +2705,7 @@ async function execute(config, input, context) {
 
     if (operation === 'diff') {
         const other = toTimestamp(
-            getFieldValue(input, config.secondField),
+            smartbotic.utils.getFieldValue(input, config.secondField),
             config.secondField || 'secondField'
         );
         result[outputField] = (timestamp - other) / step;
@@ -2889,7 +2773,7 @@ git commit -m "feat: a Date and Time node, honest about having no timezone datab
 
 ---
 
-### Task 15: Stop and Error, and the docs
+### Task 16: Stop and Error, and the docs
 
 **Files:**
 - Create: `nodes/core/stop-and-error.js`
@@ -3013,7 +2897,20 @@ well-stocked, those matter more than any ten integrations.
 
 - [ ] **Step 5: Document the two new declarations**
 
-In `docs/nodes.md`, after the "Custom Outputs" section (line 167), add:
+In `docs/nodes.md`, in the "Utilities" section, add the shared path helper to the listed `smartbotic.utils` calls:
+
+```javascript
+// Read a nested value by dotted path
+const city = smartbotic.utils.getFieldValue(input, 'data.user.address.city');
+const second = smartbotic.utils.getFieldValue(input, 'data.items[1].id');
+const howMany = smartbotic.utils.getFieldValue(input, 'data.items.length');
+```
+
+Missing paths return `undefined`. Arrays are reached with `key[0]` or a bare
+numeric segment, and `.length` works on an array. The path is taken literally,
+with no special handling of a leading `data.` segment.
+
+Then, after the "Custom Outputs" section (line 167), add:
 
 ````markdown
 ## Outputs That Come From Config
@@ -3088,6 +2985,6 @@ git commit -m "feat: a Stop and Error node, and docs for the two new port declar
 
 **If a branch test routes wrongly.** Print the node's full output from the harness listing. `_activeBranch` must name a port that also exists as a key on the returned object - `{_activeBranch: 'case1', case1: data}`. A missing key falls back to `output.data` (`workflow_engine.cpp:1243`), which looks like success but carries the wrong payload.
 
-**If the editor draws no ports for switch.** Check the REST payload first (`curl .../nodes/switch | jq .dynamicOutputs`). If it is there, the break is in `resolveOutputs` or in the three editor sites from Task 6, not in the C++.
+**If the editor draws no ports for switch.** Check the REST payload first (`curl .../nodes/switch | jq .dynamicOutputs`). If it is there, the break is in `resolveOutputs` or in the three editor sites from Task 7, not in the C++.
 
 **Restarting services.** Always both, always after any C++ build. The runner caches node definitions over a gRPC stream, so a stale runner will happily execute the previous version of a node and the failure looks like a node bug.