jsfe 1.25.0 → 1.25.1

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
@@ -574,7 +574,7 @@ This architecture prevents validation errors for sub-flows that depend on variab
574
574
 
575
575
  ##### Flow traps: `validateFlow`, `validateAllFlows`, `findFlowTraps`
576
576
 
577
- The validator reports each real trap once, as an ERROR: `stack_growth` (a loop holding a call) and `spin` (a loop of replaces with no question in reach). A flow replacing itself, reboot, DISPATCH, a `toParent` return to a `replaceTarget` flow and a shared worker in the path are not traps. A flow on a loop that a runtime limit ends declares it with `loopBound: "<the limit>"`, and a test must drive the loop to that limit: the static check cannot read the guard.
577
+ The validator reports each real trap once, as an ERROR: `stack_growth` (a loop holding a call) and `spin` (a loop of replaces, or one closed by a parent REPLACE, with no question in reach). A flow replacing itself, reboot, DISPATCH, a `toParent` return to a `replaceTarget` flow and a shared worker in the path are not traps. A flow on a loop that a runtime limit ends declares it with `loopBound: "<the limit>"`, and a test must drive the loop to that limit: the static check cannot read the guard.
578
578
 
579
579
  ##### Thin wrappers: `wraps`
580
580
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The traps of a set of flows: [{ kind: 'stack_growth'|'spin', flows, edges: ['a -call-> b', …] }], each once.
3
- * `stack_growth`: a loop holding at least one call. `spin`: a loop of replaces where no flow can reach a question.
3
+ * `stack_growth`: a loop holding at least one call. `spin`: a loop of replaces, or one closed by a REPLACE of the parent, where no flow can reach a question.
4
4
  */
5
5
  export function findFlowTraps(flows: any): {
6
6
  kind: string;
@@ -1 +1 @@
1
- {"version":3,"file":"flow-traps.d.ts","sourceRoot":"","sources":["../../src/forms/flow-traps.js"],"names":[],"mappings":"AAuFA;;;GAGG;AACH;;;;IAoCC;AAED,mCAAmC;AACnC,kDAGC"}
1
+ {"version":3,"file":"flow-traps.d.ts","sourceRoot":"","sources":["../../src/forms/flow-traps.js"],"names":[],"mappings":"AAuFA;;;GAGG;AACH;;;;IA2CC;AAED,mCAAmC;AACnC,kDAGC"}
@@ -2,7 +2,7 @@
2
2
  * Flow traps: the flow-graph defects that are real (owner 2026-11-01). A call on a loop grows the stack every lap; a
3
3
  * loop of replaces with no question in reach spins inside one turn. Not traps: a flow replacing itself (the frame is
4
4
  * reused and a question is in reach), reboot and DISPATCH (both clear the stack), a `toParent` REPLACE of a
5
- * `replaceTarget` flow (it returns to the waiting frame), a loop whose flow declares `loopBound`, and a worker in the path (it runs whichever
5
+ * `replaceTarget` flow with a question in reach (it returns to the waiting frame), a loop whose flow declares `loopBound`, and a worker in the path (it runs whichever
6
6
  * form asked, so two forms sharing run-form are nesting, not a loop). A worker's own loops are not read here.
7
7
  *
8
8
  * Targets named in a declaration count like the FLOW step that will run them: form input callbacks and validators,
@@ -27,7 +27,7 @@ function walk(node, visit) {
27
27
  visit(node);
28
28
  Object.values(node).forEach((value) => walk(value, visit));
29
29
  }
30
- /** Every edge of one flow: { to, kind: 'call'|'replace', via } and whether the flow itself can put a question. */
30
+ /** Every edge of one flow: { to, kind: 'call'|'replace'|'return', via } and whether the flow itself can put a question. */
31
31
  function flowEdges(flow, resolve, returning, isReplaceTarget) {
32
32
  const edges = [];
33
33
  let asks = false;
@@ -36,7 +36,7 @@ function flowEdges(flow, resolve, returning, isReplaceTarget) {
36
36
  if (!id || WORKER_FLOW_IDS.has(id))
37
37
  return;
38
38
  if (kind === 'replace' && toParent && isReplaceTarget(id))
39
- return; // it returns to the waiting frame: no growth
39
+ kind = 'return'; // to the waiting frame: no growth, still a lap
40
40
  if (!edges.some((edge) => edge.to === id && edge.kind === kind))
41
41
  edges.push({ to: id, kind, via });
42
42
  };
@@ -117,7 +117,7 @@ function stronglyConnected(graph) {
117
117
  }
118
118
  /**
119
119
  * The traps of a set of flows: [{ kind: 'stack_growth'|'spin', flows, edges: ['a -call-> b', …] }], each once.
120
- * `stack_growth`: a loop holding at least one call. `spin`: a loop of replaces where no flow can reach a question.
120
+ * `stack_growth`: a loop holding at least one call. `spin`: a loop of replaces, or one closed by a REPLACE of the parent, where no flow can reach a question.
121
121
  */
122
122
  export function findFlowTraps(flows) {
123
123
  const byName = new Map();
@@ -137,9 +137,9 @@ export function findFlowTraps(flows) {
137
137
  asks.set(flow.id, read.asks);
138
138
  }
139
139
  for (const { from, to } of returning) {
140
- const target = byName.get(to);
141
- if (target && !markedTargets.has(target) && graph.has(from) && !graph.get(from).some((edge) => edge.to === target && edge.kind === 'replace'))
142
- graph.get(from).push({ to: target, kind: 'replace', via: 'retry_flow' });
140
+ const target = byName.get(to), kind = markedTargets.has(target) ? 'return' : 'replace';
141
+ if (target && graph.has(from) && !graph.get(from).some((edge) => edge.to === target && edge.kind === kind))
142
+ graph.get(from).push({ to: target, kind, via: 'retry_flow' });
143
143
  }
144
144
  const reachesQuestion = (start) => {
145
145
  const seen = new Set([start]), queue = [start];
@@ -155,20 +155,29 @@ export function findFlowTraps(flows) {
155
155
  }
156
156
  return false;
157
157
  };
158
+ const describe = (edge) => `${edge.from} -${edge.kind === 'call' ? 'call' : edge.kind === 'return' ? 'replace-parent' : 'replace'}${edge.via === 'step' ? '' : `(${edge.via})`}-> ${edge.to}`;
159
+ const innerEdges = (group, edgesOf) => { const members = new Set(group); return group.flatMap((from) => edgesOf(from).filter((edge) => members.has(edge.to)).map((edge) => ({ from, ...edge }))); };
160
+ const framed = (from) => (graph.get(from) || []).filter((edge) => edge.kind !== 'return');
158
161
  const traps = [];
159
- for (const group of stronglyConnected(graph)) {
160
- const members = new Set(group);
161
- const inner = group.flatMap((from) => (graph.get(from) || []).filter((edge) => members.has(edge.to)).map((edge) => ({ from, ...edge })));
162
+ for (const group of stronglyConnected(new Map([...graph.keys()].map((from) => [from, framed(from)])))) {
163
+ const inner = innerEdges(group, framed);
162
164
  if (!inner.length)
163
165
  continue;
164
166
  if (group.some((flow) => bounded.has(flow)))
165
167
  continue; // a runtime limit ends this loop, and a test drives it there
166
- const describe = (edge) => `${edge.from} -${edge.kind === 'call' ? 'call' : 'replace'}${edge.via === 'step' ? '' : `(${edge.via})`}-> ${edge.to}`;
167
168
  if (inner.some((edge) => edge.kind === 'call'))
168
169
  traps.push({ kind: 'stack_growth', flows: [...group].sort(), edges: inner.map(describe).sort() });
169
170
  else if (!group.some(reachesQuestion))
170
171
  traps.push({ kind: 'spin', flows: [...group].sort(), edges: inner.map(describe).sort() });
171
172
  }
173
+ // A REPLACE of the parent keeps the stack flat, but it is still a lap: with no question in reach it never ends.
174
+ for (const group of stronglyConnected(graph)) {
175
+ const inner = innerEdges(group, (from) => graph.get(from) || []);
176
+ if (!inner.some((edge) => edge.kind === 'return') || group.some((flow) => bounded.has(flow)) || group.some(reachesQuestion))
177
+ continue;
178
+ if (!traps.some((trap) => trap.kind === 'spin' && trap.flows.some((flow) => group.includes(flow))))
179
+ traps.push({ kind: 'spin', flows: [...group].sort(), edges: inner.map(describe).sort() });
180
+ }
172
181
  return traps.sort((a, b) => a.flows[0].localeCompare(b.flows[0]));
173
182
  }
174
183
  /** One validator line per trap. */
@@ -1 +1 @@
1
- export const JSFE_RUNTIME_FINGERPRINT = "sha256:e2950c0d214f6bf1b57b85eaa1cc8c76b2cb1707d4dedb066c056f874607bc27";
1
+ export const JSFE_RUNTIME_FINGERPRINT = "sha256:f5b93873ae79e43362048a7a5d2627b4a4ea86f6ab84c6bb1096961d3b4fcc63";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsfe",
3
- "version": "1.25.0",
3
+ "version": "1.25.1",
4
4
  "description": "TypeScript workflow engine for conversational/automation apps: multi-flow orchestration, interrupt/resume, stack-based control, robust errors, and flexible tool/REST integration.",
5
5
  "license": "MIT",
6
6
  "author": "Ron Pinkas",