@contractspec/lib.workflow-composer 3.7.10 → 3.7.14

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
@@ -23,6 +23,13 @@ or
23
23
 
24
24
  Import the root entrypoint from `@contractspec/lib.workflow-composer`, or choose a documented subpath when you only need one part of the package surface.
25
25
 
26
+ Workflow composition is deterministic by default:
27
+
28
+ - Extensions are applied in stable priority order.
29
+ - Duplicate injected step ids and invalid anchor combinations are rejected.
30
+ - Hidden-step overlays are validated so they cannot orphan the remaining graph.
31
+ - Extension `metadata` and `annotations` are merged into the composed workflow output.
32
+
26
33
  ## Architecture
27
34
 
28
35
  - `src/composer.test.ts` is part of the package's public or composition surface.
@@ -10,6 +10,9 @@ import {
10
10
  function validateExtension(extension, base) {
11
11
  const issues = [];
12
12
  const baseStepIds = new Set(base.definition.steps.map((step) => step.id));
13
+ const hiddenSteps = new Set(extension.hiddenSteps ?? []);
14
+ const visibleStepIds = new Set(baseStepIds);
15
+ const visibleTransitions = base.definition.transitions.filter((transition) => visibleStepIds.has(transition.from) && visibleStepIds.has(transition.to)).map((transition) => ({ ...transition }));
13
16
  if (!extension.workflow.trim()) {
14
17
  issues.push({
15
18
  code: "workflow.extension.workflow",
@@ -22,15 +25,17 @@ function validateExtension(extension, base) {
22
25
  message: `extension targets "${extension.workflow}" but base workflow is "${base.meta.key}"`
23
26
  });
24
27
  }
25
- const hiddenSteps = new Set(extension.hiddenSteps ?? []);
26
28
  hiddenSteps.forEach((stepId) => {
27
29
  if (!baseStepIds.has(stepId)) {
28
30
  issues.push({
29
31
  code: "workflow.extension.hidden-step",
30
32
  message: `hidden step "${stepId}" does not exist`
31
33
  });
34
+ return;
32
35
  }
36
+ visibleStepIds.delete(stepId);
33
37
  });
38
+ removeHiddenTransitions(visibleTransitions, hiddenSteps);
34
39
  const availableAnchorSteps = new Set([...baseStepIds].filter((stepId) => !hiddenSteps.has(stepId)));
35
40
  extension.customSteps?.forEach((injection, idx) => {
36
41
  const injectionId = injection.inject.id?.trim();
@@ -90,6 +95,21 @@ function validateExtension(extension, base) {
90
95
  });
91
96
  }
92
97
  availableAnchorSteps.add(injectionId);
98
+ visibleStepIds.add(injectionId);
99
+ if (injection.transitionFrom) {
100
+ visibleTransitions.push({
101
+ from: injection.transitionFrom,
102
+ to: injectionId,
103
+ condition: injection.when
104
+ });
105
+ }
106
+ if (injection.transitionTo) {
107
+ visibleTransitions.push({
108
+ from: injectionId,
109
+ to: injection.transitionTo,
110
+ condition: injection.when
111
+ });
112
+ }
93
113
  });
94
114
  const entryStepId = base.definition.entryStepId ?? base.definition.steps[0]?.id;
95
115
  if (entryStepId && hiddenSteps.has(entryStepId)) {
@@ -98,11 +118,54 @@ function validateExtension(extension, base) {
98
118
  message: `hiddenSteps removes the entry step "${entryStepId}"`
99
119
  });
100
120
  }
121
+ if (entryStepId && !hiddenSteps.has(entryStepId)) {
122
+ const reachable = collectReachableSteps(entryStepId, visibleTransitions);
123
+ for (const stepId of visibleStepIds) {
124
+ if (!reachable.has(stepId)) {
125
+ issues.push({
126
+ code: "workflow.extension.hidden-step.orphan",
127
+ message: `extension leaves step "${stepId}" unreachable from entry step "${entryStepId}"`
128
+ });
129
+ }
130
+ }
131
+ }
101
132
  if (issues.length) {
102
133
  const reason = issues.map((issue) => `${issue.code}: ${issue.message}`).join("; ");
103
134
  throw new Error(`Invalid workflow extension for ${extension.workflow}: ${reason}`);
104
135
  }
105
136
  }
137
+ function removeHiddenTransitions(transitions, hiddenSteps) {
138
+ for (let index = transitions.length - 1;index >= 0; index -= 1) {
139
+ const transition = transitions[index];
140
+ if (!transition) {
141
+ continue;
142
+ }
143
+ if (hiddenSteps.has(transition.from) || hiddenSteps.has(transition.to)) {
144
+ transitions.splice(index, 1);
145
+ }
146
+ }
147
+ }
148
+ function collectReachableSteps(entryStepId, transitions) {
149
+ const adjacency = new Map;
150
+ for (const transition of transitions) {
151
+ const next = adjacency.get(transition.from) ?? [];
152
+ next.push(transition.to);
153
+ adjacency.set(transition.from, next);
154
+ }
155
+ const reachable = new Set;
156
+ const queue = [entryStepId];
157
+ while (queue.length > 0) {
158
+ const current = queue.shift();
159
+ if (!current || reachable.has(current)) {
160
+ continue;
161
+ }
162
+ reachable.add(current);
163
+ for (const next of adjacency.get(current) ?? []) {
164
+ queue.push(next);
165
+ }
166
+ }
167
+ return reachable;
168
+ }
106
169
 
107
170
  // src/injector.ts
108
171
  function applyWorkflowExtension(base, extension) {
package/dist/index.js CHANGED
@@ -11,6 +11,9 @@ import {
11
11
  function validateExtension(extension, base) {
12
12
  const issues = [];
13
13
  const baseStepIds = new Set(base.definition.steps.map((step) => step.id));
14
+ const hiddenSteps = new Set(extension.hiddenSteps ?? []);
15
+ const visibleStepIds = new Set(baseStepIds);
16
+ const visibleTransitions = base.definition.transitions.filter((transition) => visibleStepIds.has(transition.from) && visibleStepIds.has(transition.to)).map((transition) => ({ ...transition }));
14
17
  if (!extension.workflow.trim()) {
15
18
  issues.push({
16
19
  code: "workflow.extension.workflow",
@@ -23,15 +26,17 @@ function validateExtension(extension, base) {
23
26
  message: `extension targets "${extension.workflow}" but base workflow is "${base.meta.key}"`
24
27
  });
25
28
  }
26
- const hiddenSteps = new Set(extension.hiddenSteps ?? []);
27
29
  hiddenSteps.forEach((stepId) => {
28
30
  if (!baseStepIds.has(stepId)) {
29
31
  issues.push({
30
32
  code: "workflow.extension.hidden-step",
31
33
  message: `hidden step "${stepId}" does not exist`
32
34
  });
35
+ return;
33
36
  }
37
+ visibleStepIds.delete(stepId);
34
38
  });
39
+ removeHiddenTransitions(visibleTransitions, hiddenSteps);
35
40
  const availableAnchorSteps = new Set([...baseStepIds].filter((stepId) => !hiddenSteps.has(stepId)));
36
41
  extension.customSteps?.forEach((injection, idx) => {
37
42
  const injectionId = injection.inject.id?.trim();
@@ -91,6 +96,21 @@ function validateExtension(extension, base) {
91
96
  });
92
97
  }
93
98
  availableAnchorSteps.add(injectionId);
99
+ visibleStepIds.add(injectionId);
100
+ if (injection.transitionFrom) {
101
+ visibleTransitions.push({
102
+ from: injection.transitionFrom,
103
+ to: injectionId,
104
+ condition: injection.when
105
+ });
106
+ }
107
+ if (injection.transitionTo) {
108
+ visibleTransitions.push({
109
+ from: injectionId,
110
+ to: injection.transitionTo,
111
+ condition: injection.when
112
+ });
113
+ }
94
114
  });
95
115
  const entryStepId = base.definition.entryStepId ?? base.definition.steps[0]?.id;
96
116
  if (entryStepId && hiddenSteps.has(entryStepId)) {
@@ -99,11 +119,54 @@ function validateExtension(extension, base) {
99
119
  message: `hiddenSteps removes the entry step "${entryStepId}"`
100
120
  });
101
121
  }
122
+ if (entryStepId && !hiddenSteps.has(entryStepId)) {
123
+ const reachable = collectReachableSteps(entryStepId, visibleTransitions);
124
+ for (const stepId of visibleStepIds) {
125
+ if (!reachable.has(stepId)) {
126
+ issues.push({
127
+ code: "workflow.extension.hidden-step.orphan",
128
+ message: `extension leaves step "${stepId}" unreachable from entry step "${entryStepId}"`
129
+ });
130
+ }
131
+ }
132
+ }
102
133
  if (issues.length) {
103
134
  const reason = issues.map((issue) => `${issue.code}: ${issue.message}`).join("; ");
104
135
  throw new Error(`Invalid workflow extension for ${extension.workflow}: ${reason}`);
105
136
  }
106
137
  }
138
+ function removeHiddenTransitions(transitions, hiddenSteps) {
139
+ for (let index = transitions.length - 1;index >= 0; index -= 1) {
140
+ const transition = transitions[index];
141
+ if (!transition) {
142
+ continue;
143
+ }
144
+ if (hiddenSteps.has(transition.from) || hiddenSteps.has(transition.to)) {
145
+ transitions.splice(index, 1);
146
+ }
147
+ }
148
+ }
149
+ function collectReachableSteps(entryStepId, transitions) {
150
+ const adjacency = new Map;
151
+ for (const transition of transitions) {
152
+ const next = adjacency.get(transition.from) ?? [];
153
+ next.push(transition.to);
154
+ adjacency.set(transition.from, next);
155
+ }
156
+ const reachable = new Set;
157
+ const queue = [entryStepId];
158
+ while (queue.length > 0) {
159
+ const current = queue.shift();
160
+ if (!current || reachable.has(current)) {
161
+ continue;
162
+ }
163
+ reachable.add(current);
164
+ for (const next of adjacency.get(current) ?? []) {
165
+ queue.push(next);
166
+ }
167
+ }
168
+ return reachable;
169
+ }
107
170
 
108
171
  // src/injector.ts
109
172
  function applyWorkflowExtension(base, extension) {
@@ -10,6 +10,9 @@ import {
10
10
  function validateExtension(extension, base) {
11
11
  const issues = [];
12
12
  const baseStepIds = new Set(base.definition.steps.map((step) => step.id));
13
+ const hiddenSteps = new Set(extension.hiddenSteps ?? []);
14
+ const visibleStepIds = new Set(baseStepIds);
15
+ const visibleTransitions = base.definition.transitions.filter((transition) => visibleStepIds.has(transition.from) && visibleStepIds.has(transition.to)).map((transition) => ({ ...transition }));
13
16
  if (!extension.workflow.trim()) {
14
17
  issues.push({
15
18
  code: "workflow.extension.workflow",
@@ -22,15 +25,17 @@ function validateExtension(extension, base) {
22
25
  message: `extension targets "${extension.workflow}" but base workflow is "${base.meta.key}"`
23
26
  });
24
27
  }
25
- const hiddenSteps = new Set(extension.hiddenSteps ?? []);
26
28
  hiddenSteps.forEach((stepId) => {
27
29
  if (!baseStepIds.has(stepId)) {
28
30
  issues.push({
29
31
  code: "workflow.extension.hidden-step",
30
32
  message: `hidden step "${stepId}" does not exist`
31
33
  });
34
+ return;
32
35
  }
36
+ visibleStepIds.delete(stepId);
33
37
  });
38
+ removeHiddenTransitions(visibleTransitions, hiddenSteps);
34
39
  const availableAnchorSteps = new Set([...baseStepIds].filter((stepId) => !hiddenSteps.has(stepId)));
35
40
  extension.customSteps?.forEach((injection, idx) => {
36
41
  const injectionId = injection.inject.id?.trim();
@@ -90,6 +95,21 @@ function validateExtension(extension, base) {
90
95
  });
91
96
  }
92
97
  availableAnchorSteps.add(injectionId);
98
+ visibleStepIds.add(injectionId);
99
+ if (injection.transitionFrom) {
100
+ visibleTransitions.push({
101
+ from: injection.transitionFrom,
102
+ to: injectionId,
103
+ condition: injection.when
104
+ });
105
+ }
106
+ if (injection.transitionTo) {
107
+ visibleTransitions.push({
108
+ from: injectionId,
109
+ to: injection.transitionTo,
110
+ condition: injection.when
111
+ });
112
+ }
93
113
  });
94
114
  const entryStepId = base.definition.entryStepId ?? base.definition.steps[0]?.id;
95
115
  if (entryStepId && hiddenSteps.has(entryStepId)) {
@@ -98,11 +118,54 @@ function validateExtension(extension, base) {
98
118
  message: `hiddenSteps removes the entry step "${entryStepId}"`
99
119
  });
100
120
  }
121
+ if (entryStepId && !hiddenSteps.has(entryStepId)) {
122
+ const reachable = collectReachableSteps(entryStepId, visibleTransitions);
123
+ for (const stepId of visibleStepIds) {
124
+ if (!reachable.has(stepId)) {
125
+ issues.push({
126
+ code: "workflow.extension.hidden-step.orphan",
127
+ message: `extension leaves step "${stepId}" unreachable from entry step "${entryStepId}"`
128
+ });
129
+ }
130
+ }
131
+ }
101
132
  if (issues.length) {
102
133
  const reason = issues.map((issue) => `${issue.code}: ${issue.message}`).join("; ");
103
134
  throw new Error(`Invalid workflow extension for ${extension.workflow}: ${reason}`);
104
135
  }
105
136
  }
137
+ function removeHiddenTransitions(transitions, hiddenSteps) {
138
+ for (let index = transitions.length - 1;index >= 0; index -= 1) {
139
+ const transition = transitions[index];
140
+ if (!transition) {
141
+ continue;
142
+ }
143
+ if (hiddenSteps.has(transition.from) || hiddenSteps.has(transition.to)) {
144
+ transitions.splice(index, 1);
145
+ }
146
+ }
147
+ }
148
+ function collectReachableSteps(entryStepId, transitions) {
149
+ const adjacency = new Map;
150
+ for (const transition of transitions) {
151
+ const next = adjacency.get(transition.from) ?? [];
152
+ next.push(transition.to);
153
+ adjacency.set(transition.from, next);
154
+ }
155
+ const reachable = new Set;
156
+ const queue = [entryStepId];
157
+ while (queue.length > 0) {
158
+ const current = queue.shift();
159
+ if (!current || reachable.has(current)) {
160
+ continue;
161
+ }
162
+ reachable.add(current);
163
+ for (const next of adjacency.get(current) ?? []) {
164
+ queue.push(next);
165
+ }
166
+ }
167
+ return reachable;
168
+ }
106
169
 
107
170
  // src/injector.ts
108
171
  function applyWorkflowExtension(base, extension) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractspec/lib.workflow-composer",
3
- "version": "3.7.10",
3
+ "version": "3.7.14",
4
4
  "description": "Tenant-aware workflow composition helpers for ContractSpec.",
5
5
  "keywords": [
6
6
  "contractspec",
@@ -31,14 +31,14 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@contractspec/lib.contracts-spec": "4.1.2",
35
- "@contractspec/lib.ai-providers": "3.7.8",
34
+ "@contractspec/lib.contracts-spec": "5.0.2",
35
+ "@contractspec/lib.ai-providers": "3.7.10",
36
36
  "compare-versions": "^6.1.1"
37
37
  },
38
38
  "devDependencies": {
39
- "@contractspec/tool.typescript": "3.7.8",
39
+ "@contractspec/tool.typescript": "3.7.10",
40
40
  "typescript": "^5.9.3",
41
- "@contractspec/tool.bun": "3.7.8"
41
+ "@contractspec/tool.bun": "3.7.10"
42
42
  },
43
43
  "exports": {
44
44
  ".": {