@tagma/sdk 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/README.md +85 -57
  2. package/dist/approval.d.ts +2 -12
  3. package/dist/approval.d.ts.map +1 -1
  4. package/dist/approval.js +1 -90
  5. package/dist/approval.js.map +1 -1
  6. package/dist/bootstrap.d.ts +1 -1
  7. package/dist/bootstrap.d.ts.map +1 -1
  8. package/dist/completions/file-exists.js +1 -1
  9. package/dist/completions/file-exists.js.map +1 -1
  10. package/dist/completions/output-check.d.ts.map +1 -1
  11. package/dist/completions/output-check.js +17 -4
  12. package/dist/completions/output-check.js.map +1 -1
  13. package/dist/config.d.ts +4 -4
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +2 -2
  16. package/dist/config.js.map +1 -1
  17. package/dist/dataflow.d.ts +3 -0
  18. package/dist/dataflow.d.ts.map +1 -0
  19. package/dist/dataflow.js +2 -0
  20. package/dist/dataflow.js.map +1 -0
  21. package/dist/drivers/opencode.d.ts.map +1 -1
  22. package/dist/drivers/opencode.js +23 -71
  23. package/dist/drivers/opencode.js.map +1 -1
  24. package/dist/engine.d.ts +5 -56
  25. package/dist/engine.d.ts.map +1 -1
  26. package/dist/engine.js +7 -297
  27. package/dist/engine.js.map +1 -1
  28. package/dist/index.d.ts +4 -6
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -4
  31. package/dist/index.js.map +1 -1
  32. package/dist/logger.d.ts +2 -60
  33. package/dist/logger.d.ts.map +1 -1
  34. package/dist/logger.js +1 -153
  35. package/dist/logger.js.map +1 -1
  36. package/dist/middlewares/static-context.d.ts.map +1 -1
  37. package/dist/middlewares/static-context.js +1 -2
  38. package/dist/middlewares/static-context.js.map +1 -1
  39. package/dist/pipeline-runner.d.ts.map +1 -1
  40. package/dist/pipeline-runner.js +2 -2
  41. package/dist/pipeline-runner.js.map +1 -1
  42. package/dist/plugins.d.ts +2 -2
  43. package/dist/plugins.d.ts.map +1 -1
  44. package/dist/plugins.js +1 -1
  45. package/dist/plugins.js.map +1 -1
  46. package/dist/runner.d.ts +1 -35
  47. package/dist/runner.d.ts.map +1 -1
  48. package/dist/runner.js +1 -610
  49. package/dist/runner.js.map +1 -1
  50. package/dist/runtime/adapters/stdin-approval.d.ts +2 -0
  51. package/dist/runtime/adapters/stdin-approval.d.ts.map +1 -0
  52. package/dist/runtime/adapters/stdin-approval.js +2 -0
  53. package/dist/runtime/adapters/stdin-approval.js.map +1 -0
  54. package/dist/runtime/adapters/websocket-approval.d.ts +2 -0
  55. package/dist/runtime/adapters/websocket-approval.d.ts.map +1 -0
  56. package/dist/runtime/adapters/websocket-approval.js +2 -0
  57. package/dist/runtime/adapters/websocket-approval.js.map +1 -0
  58. package/dist/runtime/bun-process-runner.d.ts +2 -0
  59. package/dist/runtime/bun-process-runner.d.ts.map +1 -0
  60. package/dist/runtime/bun-process-runner.js +2 -0
  61. package/dist/runtime/bun-process-runner.js.map +1 -0
  62. package/dist/runtime.d.ts +2 -8
  63. package/dist/runtime.d.ts.map +1 -1
  64. package/dist/runtime.js +1 -7
  65. package/dist/runtime.js.map +1 -1
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +3 -4
  68. package/dist/schema.js.map +1 -1
  69. package/dist/tagma.d.ts +3 -4
  70. package/dist/tagma.d.ts.map +1 -1
  71. package/dist/tagma.js +2 -3
  72. package/dist/tagma.js.map +1 -1
  73. package/dist/triggers/file.d.ts.map +1 -1
  74. package/dist/triggers/file.js +74 -108
  75. package/dist/triggers/file.js.map +1 -1
  76. package/dist/triggers/manual.d.ts.map +1 -1
  77. package/dist/triggers/manual.js +1 -2
  78. package/dist/triggers/manual.js.map +1 -1
  79. package/dist/types.d.ts +1 -2
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/types.js +1 -12
  82. package/dist/types.js.map +1 -1
  83. package/dist/utils-api.d.ts +1 -1
  84. package/dist/utils-api.d.ts.map +1 -1
  85. package/dist/utils-api.js +1 -1
  86. package/dist/utils-api.js.map +1 -1
  87. package/dist/validate-raw.d.ts.map +1 -1
  88. package/dist/validate-raw.js +5 -12
  89. package/dist/validate-raw.js.map +1 -1
  90. package/package.json +20 -22
  91. package/dist/adapters/stdin-approval.d.ts +0 -6
  92. package/dist/adapters/stdin-approval.d.ts.map +0 -1
  93. package/dist/adapters/stdin-approval.js +0 -90
  94. package/dist/adapters/stdin-approval.js.map +0 -1
  95. package/dist/adapters/websocket-approval.d.ts +0 -28
  96. package/dist/adapters/websocket-approval.d.ts.map +0 -1
  97. package/dist/adapters/websocket-approval.js +0 -147
  98. package/dist/adapters/websocket-approval.js.map +0 -1
  99. package/dist/core/dataflow.d.ts +0 -23
  100. package/dist/core/dataflow.d.ts.map +0 -1
  101. package/dist/core/dataflow.js +0 -99
  102. package/dist/core/dataflow.js.map +0 -1
  103. package/dist/core/log-prune.d.ts +0 -16
  104. package/dist/core/log-prune.d.ts.map +0 -1
  105. package/dist/core/log-prune.js +0 -34
  106. package/dist/core/log-prune.js.map +0 -1
  107. package/dist/core/preflight.d.ts +0 -13
  108. package/dist/core/preflight.d.ts.map +0 -1
  109. package/dist/core/preflight.js +0 -61
  110. package/dist/core/preflight.js.map +0 -1
  111. package/dist/core/run-context.d.ts +0 -55
  112. package/dist/core/run-context.d.ts.map +0 -1
  113. package/dist/core/run-context.js +0 -158
  114. package/dist/core/run-context.js.map +0 -1
  115. package/dist/core/run-state.d.ts +0 -25
  116. package/dist/core/run-state.d.ts.map +0 -1
  117. package/dist/core/run-state.js +0 -93
  118. package/dist/core/run-state.js.map +0 -1
  119. package/dist/core/scheduler.d.ts +0 -13
  120. package/dist/core/scheduler.d.ts.map +0 -1
  121. package/dist/core/scheduler.js +0 -35
  122. package/dist/core/scheduler.js.map +0 -1
  123. package/dist/core/task-executor.d.ts +0 -13
  124. package/dist/core/task-executor.d.ts.map +0 -1
  125. package/dist/core/task-executor.js +0 -601
  126. package/dist/core/task-executor.js.map +0 -1
  127. package/dist/core/trigger-errors.d.ts +0 -9
  128. package/dist/core/trigger-errors.d.ts.map +0 -1
  129. package/dist/core/trigger-errors.js +0 -15
  130. package/dist/core/trigger-errors.js.map +0 -1
  131. package/dist/dag.d.ts +0 -45
  132. package/dist/dag.d.ts.map +0 -1
  133. package/dist/dag.js +0 -177
  134. package/dist/dag.js.map +0 -1
  135. package/dist/hooks.d.ts +0 -73
  136. package/dist/hooks.d.ts.map +0 -1
  137. package/dist/hooks.js +0 -106
  138. package/dist/hooks.js.map +0 -1
  139. package/dist/pipeline-definition.d.ts +0 -3
  140. package/dist/pipeline-definition.d.ts.map +0 -1
  141. package/dist/pipeline-definition.js +0 -4
  142. package/dist/pipeline-definition.js.map +0 -1
  143. package/dist/ports.d.ts +0 -196
  144. package/dist/ports.d.ts.map +0 -1
  145. package/dist/ports.js +0 -688
  146. package/dist/ports.js.map +0 -1
  147. package/dist/prompt-doc.d.ts +0 -70
  148. package/dist/prompt-doc.d.ts.map +0 -1
  149. package/dist/prompt-doc.js +0 -154
  150. package/dist/prompt-doc.js.map +0 -1
  151. package/dist/registry.d.ts +0 -67
  152. package/dist/registry.d.ts.map +0 -1
  153. package/dist/registry.js +0 -293
  154. package/dist/registry.js.map +0 -1
  155. package/dist/task-ref.d.ts +0 -55
  156. package/dist/task-ref.d.ts.map +0 -1
  157. package/dist/task-ref.js +0 -103
  158. package/dist/task-ref.js.map +0 -1
  159. package/dist/utils.d.ts +0 -13
  160. package/dist/utils.d.ts.map +0 -1
  161. package/dist/utils.js +0 -177
  162. package/dist/utils.js.map +0 -1
  163. package/src/adapters/stdin-approval.ts +0 -106
  164. package/src/adapters/websocket-approval.ts +0 -224
  165. package/src/approval.ts +0 -131
  166. package/src/bootstrap.ts +0 -55
  167. package/src/completions/exit-code.ts +0 -34
  168. package/src/completions/file-exists.ts +0 -66
  169. package/src/completions/output-check.test.ts +0 -50
  170. package/src/completions/output-check.ts +0 -92
  171. package/src/config-ops.test.ts +0 -70
  172. package/src/config-ops.ts +0 -328
  173. package/src/config.ts +0 -26
  174. package/src/core/dataflow.test.ts +0 -166
  175. package/src/core/dataflow.ts +0 -161
  176. package/src/core/log-prune.test.ts +0 -58
  177. package/src/core/log-prune.ts +0 -43
  178. package/src/core/preflight.test.ts +0 -49
  179. package/src/core/preflight.ts +0 -89
  180. package/src/core/run-context.test.ts +0 -256
  181. package/src/core/run-context.ts +0 -211
  182. package/src/core/run-state.test.ts +0 -98
  183. package/src/core/run-state.ts +0 -122
  184. package/src/core/scheduler.test.ts +0 -83
  185. package/src/core/scheduler.ts +0 -42
  186. package/src/core/task-executor.ts +0 -743
  187. package/src/core/trigger-errors.ts +0 -15
  188. package/src/dag.test.ts +0 -56
  189. package/src/dag.ts +0 -245
  190. package/src/drivers/opencode.ts +0 -410
  191. package/src/engine-ports-mixed.test.ts +0 -156
  192. package/src/engine-ports.test.ts +0 -166
  193. package/src/engine-task-type.test.ts +0 -56
  194. package/src/engine.ts +0 -458
  195. package/src/hooks.ts +0 -193
  196. package/src/index.ts +0 -33
  197. package/src/logger.ts +0 -182
  198. package/src/middlewares/static-context.ts +0 -49
  199. package/src/pipeline-definition.ts +0 -5
  200. package/src/pipeline-runner.test.ts +0 -91
  201. package/src/pipeline-runner.ts +0 -194
  202. package/src/plugin-registry.test.ts +0 -382
  203. package/src/plugins.ts +0 -21
  204. package/src/ports.test.ts +0 -678
  205. package/src/ports.ts +0 -925
  206. package/src/prompt-doc.test.ts +0 -174
  207. package/src/prompt-doc.ts +0 -169
  208. package/src/registry.ts +0 -353
  209. package/src/runner.test.ts +0 -142
  210. package/src/runner.ts +0 -666
  211. package/src/runtime.ts +0 -20
  212. package/src/schema-ports.test.ts +0 -172
  213. package/src/schema.test.ts +0 -213
  214. package/src/schema.ts +0 -379
  215. package/src/tagma.test.ts +0 -155
  216. package/src/tagma.ts +0 -62
  217. package/src/task-ref.test.ts +0 -401
  218. package/src/task-ref.ts +0 -121
  219. package/src/triggers/file.ts +0 -164
  220. package/src/triggers/manual.ts +0 -86
  221. package/src/types.ts +0 -18
  222. package/src/utils-api.ts +0 -8
  223. package/src/utils.test.ts +0 -28
  224. package/src/utils.ts +0 -203
  225. package/src/validate-raw-plugin-types.test.ts +0 -60
  226. package/src/validate-raw-ports.test.ts +0 -136
  227. package/src/validate-raw.ts +0 -852
  228. package/src/yaml-compiler.test.ts +0 -108
  229. package/src/yaml-compiler.ts +0 -110
  230. package/src/yaml.ts +0 -11
@@ -1,852 +0,0 @@
1
- // 鈺愨晲鈺?Raw Pipeline Config Validation 鈺愨晲鈺?
2
- //
3
- // Validates a RawPipelineConfig without resolving inheritance or executing
4
- // anything 鈥?intended for real-time feedback in a visual editor (e.g. drag
5
- // to add a task, live error highlighting).
6
- //
7
- // Returns a flat list of ValidationError objects. An empty array means valid.
8
-
9
- import type {
10
- PortType,
11
- RawPipelineConfig,
12
- RawTaskConfig,
13
- RawTrackConfig,
14
- } from './types';
15
- import {
16
- isValidTaskId,
17
- qualifyTaskId,
18
- buildTaskIndex,
19
- resolveTaskRef,
20
- type TaskIndex,
21
- } from './task-ref';
22
- import { extractInputReferences } from './ports';
23
-
24
- interface QidEntry {
25
- readonly track: RawTrackConfig;
26
- readonly task: RawTaskConfig;
27
- }
28
-
29
- /** qid 鈫?{track, task} lookup built once per validation pass. */
30
- function buildQidIndex(config: RawPipelineConfig): Map<string, QidEntry> {
31
- const idx = new Map<string, QidEntry>();
32
- for (const track of config.tracks ?? []) {
33
- if (!track.id) continue;
34
- if (!Array.isArray(track.tasks)) continue;
35
- for (const task of track.tasks ?? []) {
36
- if (!task.id) continue;
37
- idx.set(qualifyTaskId(track.id, task.id), { track, task });
38
- }
39
- }
40
- return idx;
41
- }
42
-
43
- const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
44
- function isValidDuration(input: string): boolean {
45
- return DURATION_RE.test(input.trim());
46
- }
47
-
48
- // D8: IDs may only contain letters, digits, underscores, and hyphens, and must
49
- // start with a letter or underscore. Dots are explicitly forbidden because the
50
- // engine uses "trackId.taskId" as the qualified separator 鈥?a dot in either
51
- // part creates an ambiguous qualified ID and breaks resolveRef.
52
- // Canonical regex and helper live in ./task-ref so every resolver (dag.ts,
53
- // engine.ts, editor) stays in lockstep with what we accept here.
54
- const isValidId = isValidTaskId;
55
-
56
- const VALID_ON_FAILURE = new Set(['skip_downstream', 'stop_all', 'ignore']);
57
- const VALID_REASONING_EFFORT = new Set(['low', 'medium', 'high']);
58
- const PERMISSION_FIELDS = ['read', 'write', 'execute'] as const;
59
-
60
- // Built-in plugin types always known to the SDK core, regardless of which
61
- // external plugin packages are installed. These MUST stay in sync with the
62
- // types that `bootstrapBuiltins()` registers, otherwise the editor will
63
- // emit false-positive "unknown type" warnings for stock pipelines.
64
- const BUILTIN_TRIGGER_TYPES: ReadonlySet<string> = new Set(['manual', 'file']);
65
- const BUILTIN_COMPLETION_TYPES: ReadonlySet<string> = new Set([
66
- 'exit_code',
67
- 'file_exists',
68
- 'output_check',
69
- ]);
70
- const BUILTIN_MIDDLEWARE_TYPES: ReadonlySet<string> = new Set(['static_context']);
71
- const BUILTIN_DRIVER_TYPES: ReadonlySet<string> = new Set(['opencode']);
72
-
73
- /**
74
- * Optional second argument to `validateRaw`: the set of plugin types currently
75
- * registered in the SDK runtime, keyed by category. Hosts (e.g. the editor
76
- * server) pass this so `validateRaw` can emit a soft warning when a task
77
- * references a type that isn't loaded 鈥?otherwise the Task panel would show
78
- * no hint and the pipeline would only blow up at run time. Callers that
79
- * legitimately validate a config offline (before plugins are loaded) can omit
80
- * this argument and no plugin warnings will be produced.
81
- */
82
- export interface KnownPluginTypes {
83
- readonly drivers?: readonly string[];
84
- readonly triggers?: readonly string[];
85
- readonly completions?: readonly string[];
86
- readonly middlewares?: readonly string[];
87
- }
88
-
89
- export type ValidationSeverity = 'error' | 'warning';
90
-
91
- export interface ValidationError {
92
- /** JSONPath-style location, e.g. "tracks[0].tasks[1].prompt" */
93
- path: string;
94
- message: string;
95
- /**
96
- * H8: not all "errors" are equally fatal. The DAG runtime is happy to
97
- * insert implicit `continue_from 鈫?depends_on` ordering, so the matching
98
- * validate-raw check is a *style* nit, not a hard failure. Severity lets
99
- * the editor render it as a soft warning instead of blocking save / run.
100
- * Existing call sites that don't read this field still treat every entry
101
- * as fatal 鈥?defaulting `severity` to undefined preserves that behaviour.
102
- */
103
- severity?: ValidationSeverity;
104
- }
105
-
106
- /**
107
- * Validate a raw pipeline config.
108
- * Checks structure, required fields, prompt/command exclusivity,
109
- * depends_on reference integrity, and circular dependencies.
110
- *
111
- * Plugin type checks: when `knownTypes` is provided, task/track references to
112
- * trigger/completion/middleware types that are neither built-in nor in the
113
- * supplied set produce a soft warning (severity: 'warning') 鈥?these don't
114
- * block save/run but light up the Task panel so users discover the broken
115
- * reference in the editor instead of at run time. Omit `knownTypes` to skip
116
- * plugin checks entirely (offline/pre-load validation).
117
- */
118
- export function validateRaw(
119
- config: RawPipelineConfig,
120
- knownTypes?: KnownPluginTypes,
121
- ): ValidationError[] {
122
- const errors: ValidationError[] = [];
123
-
124
- const knownTriggers = knownTypes
125
- ? new Set<string>([...BUILTIN_TRIGGER_TYPES, ...(knownTypes.triggers ?? [])])
126
- : null;
127
- const knownDrivers = knownTypes
128
- ? new Set<string>([...BUILTIN_DRIVER_TYPES, ...(knownTypes.drivers ?? [])])
129
- : null;
130
- const knownCompletions = knownTypes
131
- ? new Set<string>([...BUILTIN_COMPLETION_TYPES, ...(knownTypes.completions ?? [])])
132
- : null;
133
- const knownMiddlewares = knownTypes
134
- ? new Set<string>([...BUILTIN_MIDDLEWARE_TYPES, ...(knownTypes.middlewares ?? [])])
135
- : null;
136
-
137
- // 鈹€鈹€ Top level 鈹€鈹€
138
- if (!config.name?.trim()) {
139
- errors.push({ path: 'name', message: 'Pipeline name is required' });
140
- }
141
- if (config.reasoning_effort && !VALID_REASONING_EFFORT.has(config.reasoning_effort)) {
142
- errors.push({
143
- path: 'reasoning_effort',
144
- message: `Invalid reasoning_effort "${config.reasoning_effort}". Expected "low", "medium", or "high".`,
145
- });
146
- }
147
- if (knownDrivers && config.driver && !knownDrivers.has(config.driver)) {
148
- errors.push({
149
- path: 'driver',
150
- message: `Unknown driver type "${config.driver}"`,
151
- severity: 'warning',
152
- });
153
- }
154
- validatePermissions(config.permissions, 'permissions', errors);
155
-
156
- if (!Array.isArray(config.tracks)) {
157
- errors.push({ path: 'tracks', message: 'pipeline.tracks must be an array' });
158
- return errors;
159
- }
160
- if (config.tracks.length === 0) {
161
- errors.push({ path: 'tracks', message: 'At least one track is required' });
162
- return errors; // No point going further without tracks
163
- }
164
-
165
- // 鈹€鈹€ Build qualified ID sets for cross-reference checks 鈹€鈹€
166
- // Qualified ID format: "trackId.taskId" (mirrors the engine's convention).
167
- // Shared with dag.ts so "ambiguous" / "not found" stay consistent 鈥?refs
168
- // that buildDag later throws on will be reported here as errors first.
169
- const index = buildTaskIndex(config);
170
- // Full qid 鈫?{track, task} index used by port-inference validation
171
- // to walk a Prompt task's neighbors without re-scanning the tracks.
172
- const qidIndex = buildQidIndex(config);
173
-
174
- // 鈹€鈹€ Per-track validation 鈹€鈹€
175
- const seenTrackIds = new Set<string>();
176
- for (let ti = 0; ti < config.tracks.length; ti++) {
177
- const maybeTrack = config.tracks[ti] as unknown;
178
- const trackPath = `tracks[${ti}]`;
179
- if (!maybeTrack || typeof maybeTrack !== 'object' || Array.isArray(maybeTrack)) {
180
- errors.push({ path: trackPath, message: `Track ${ti} must be an object` });
181
- continue;
182
- }
183
- const track = maybeTrack as RawTrackConfig;
184
-
185
- if (!track.id?.trim()) {
186
- errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
187
- } else if (!isValidId(track.id)) {
188
- errors.push({
189
- path: `${trackPath}.id`,
190
- message: `Track id "${track.id}" contains invalid characters. IDs must match /^[A-Za-z_][A-Za-z0-9_-]*$/ (no dots, spaces, or special chars).`,
191
- });
192
- } else if (seenTrackIds.has(track.id)) {
193
- errors.push({ path: `${trackPath}.id`, message: `Duplicate track id "${track.id}"` });
194
- } else {
195
- seenTrackIds.add(track.id);
196
- }
197
- if (!track.name?.trim()) {
198
- errors.push({ path: `${trackPath}.name`, message: 'Track name is required' });
199
- }
200
- if (track.on_failure && !VALID_ON_FAILURE.has(track.on_failure)) {
201
- errors.push({
202
- path: `${trackPath}.on_failure`,
203
- message: `Invalid on_failure value "${track.on_failure}". Expected "skip_downstream", "stop_all", or "ignore".`,
204
- });
205
- }
206
- if (track.reasoning_effort && !VALID_REASONING_EFFORT.has(track.reasoning_effort)) {
207
- errors.push({
208
- path: `${trackPath}.reasoning_effort`,
209
- message: `Invalid reasoning_effort "${track.reasoning_effort}". Expected "low", "medium", or "high".`,
210
- });
211
- }
212
- if (knownDrivers && track.driver && !knownDrivers.has(track.driver)) {
213
- errors.push({
214
- path: `${trackPath}.driver`,
215
- message: `Unknown driver type "${track.driver}"`,
216
- severity: 'warning',
217
- });
218
- }
219
- validatePermissions(track.permissions, `${trackPath}.permissions`, errors);
220
-
221
- // Track-level middlewares can reference a plugin that was uninstalled
222
- // after the YAML was written 鈥?surface a warning so the user notices
223
- // before hitting Run.
224
- if (knownMiddlewares && track.middlewares) {
225
- for (let mi = 0; mi < track.middlewares.length; mi++) {
226
- const mw = track.middlewares[mi];
227
- if (mw?.type && !knownMiddlewares.has(mw.type)) {
228
- errors.push({
229
- path: `${trackPath}.middlewares[${mi}].type`,
230
- message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference 鈥?the pipeline will fail at run time.`,
231
- severity: 'warning',
232
- });
233
- }
234
- }
235
- }
236
-
237
- if (!Array.isArray(track.tasks)) {
238
- errors.push({
239
- path: `${trackPath}.tasks`,
240
- message: `Track "${track.id || ti}": tasks must be an array`,
241
- });
242
- continue;
243
- }
244
- if (track.tasks.length === 0) {
245
- errors.push({
246
- path: `${trackPath}.tasks`,
247
- message: `Track "${track.id || ti}": must have at least one task`,
248
- });
249
- continue;
250
- }
251
-
252
- // 鈹€鈹€ Per-task validation 鈹€鈹€
253
- const seenTaskIds = new Set<string>();
254
- for (let ki = 0; ki < track.tasks.length; ki++) {
255
- const task = track.tasks[ki];
256
- const taskPath = `${trackPath}.tasks[${ki}]`;
257
-
258
- if (!task.id?.trim()) {
259
- errors.push({ path: `${taskPath}.id`, message: 'Task id is required' });
260
- continue; // Can't check further without an id
261
- }
262
-
263
- if (!isValidId(task.id)) {
264
- errors.push({
265
- path: `${taskPath}.id`,
266
- message: `Task id "${task.id}" contains invalid characters. IDs must match /^[A-Za-z_][A-Za-z0-9_-]*$/ (no dots, spaces, or special chars).`,
267
- });
268
- }
269
- if (seenTaskIds.has(task.id)) {
270
- errors.push({
271
- path: taskPath,
272
- message: `Duplicate task id "${task.id}" in track "${track.id}"`,
273
- });
274
- }
275
- seenTaskIds.add(task.id);
276
-
277
- const hasPromptKey = typeof task.prompt === 'string';
278
- const hasCommandKey = typeof task.command === 'string';
279
- const promptEmpty = hasPromptKey && task.prompt!.trim().length === 0;
280
- const commandEmpty = hasCommandKey && task.command!.trim().length === 0;
281
-
282
- if (hasPromptKey && hasCommandKey) {
283
- errors.push({
284
- path: taskPath,
285
- message: `Task "${task.id}": cannot have both "prompt" and "command"`,
286
- });
287
- } else if (!hasPromptKey && !hasCommandKey) {
288
- errors.push({
289
- path: taskPath,
290
- message: `Task "${task.id}": must have "prompt" or "command"`,
291
- });
292
- } else if (promptEmpty) {
293
- errors.push({
294
- path: taskPath,
295
- message: `Task "${task.id}": prompt content cannot be empty`,
296
- });
297
- } else if (commandEmpty) {
298
- errors.push({
299
- path: taskPath,
300
- message: `Task "${task.id}": command content cannot be empty`,
301
- });
302
- }
303
-
304
- // 鈹€鈹€ Field-level validations 鈹€鈹€
305
- if (task.timeout && !isValidDuration(task.timeout)) {
306
- errors.push({
307
- path: `${taskPath}.timeout`,
308
- message: `Invalid duration format "${task.timeout}". Expected e.g. "30s", "5m", "1h".`,
309
- });
310
- }
311
- if (task.reasoning_effort && !VALID_REASONING_EFFORT.has(task.reasoning_effort)) {
312
- errors.push({
313
- path: `${taskPath}.reasoning_effort`,
314
- message: `Invalid reasoning_effort "${task.reasoning_effort}". Expected "low", "medium", or "high".`,
315
- });
316
- }
317
- if (knownDrivers && task.driver && !knownDrivers.has(task.driver)) {
318
- errors.push({
319
- path: `${taskPath}.driver`,
320
- message: `Unknown driver type "${task.driver}"`,
321
- severity: 'warning',
322
- });
323
- }
324
- validatePermissions(task.permissions, `${taskPath}.permissions`, errors);
325
-
326
- // 鈹€鈹€ Plugin type warnings (trigger / completion / middlewares) 鈹€鈹€
327
- // Only fire when the host supplied a `knownTypes` snapshot, so offline
328
- // validation stays quiet. The messages deliberately name the npm
329
- // scope so users can copy-paste the install command.
330
- if (knownTriggers && task.trigger?.type && !knownTriggers.has(task.trigger.type)) {
331
- errors.push({
332
- path: `${taskPath}.trigger.type`,
333
- message: `Trigger type "${task.trigger.type}" is not registered. Install the plugin (e.g. @tagma/trigger-${task.trigger.type}) or the task will fail at run time.`,
334
- severity: 'warning',
335
- });
336
- }
337
- if (
338
- knownCompletions &&
339
- task.completion?.type &&
340
- !knownCompletions.has(task.completion.type)
341
- ) {
342
- errors.push({
343
- path: `${taskPath}.completion.type`,
344
- message: `Completion type "${task.completion.type}" is not registered. Install the plugin (e.g. @tagma/completion-${task.completion.type}) or the task will fail at run time.`,
345
- severity: 'warning',
346
- });
347
- }
348
- if (knownMiddlewares && task.middlewares) {
349
- for (let mi = 0; mi < task.middlewares.length; mi++) {
350
- const mw = task.middlewares[mi];
351
- if (mw?.type && !knownMiddlewares.has(mw.type)) {
352
- errors.push({
353
- path: `${taskPath}.middlewares[${mi}].type`,
354
- message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference 鈥?the pipeline will fail at run time.`,
355
- severity: 'warning',
356
- });
357
- }
358
- }
359
- }
360
-
361
- // 鈹€鈹€ Port declaration checks 鈹€鈹€
362
- validateTaskPorts(task, track.id, taskPath, qidIndex, index, errors);
363
-
364
- // 鈹€鈹€ depends_on reference checks 鈹€鈹€
365
- if (task.depends_on && task.depends_on.length > 0) {
366
- for (const dep of task.depends_on) {
367
- const resolved = resolveTaskRef(dep, track.id, index);
368
- if (resolved.kind === 'not_found') {
369
- errors.push({
370
- path: `${taskPath}.depends_on`,
371
- message: `Task "${task.id}": depends_on "${dep}" 鈥?no such task found`,
372
- });
373
- } else if (resolved.kind === 'ambiguous') {
374
- errors.push({
375
- path: `${taskPath}.depends_on`,
376
- message: `Task "${task.id}": depends_on "${dep}" is ambiguous 鈥?multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
377
- });
378
- }
379
- }
380
- }
381
-
382
- // 鈹€鈹€ continue_from reference check 鈹€鈹€
383
- if (task.continue_from) {
384
- const resolved = resolveTaskRef(task.continue_from, track.id, index);
385
- if (resolved.kind === 'not_found') {
386
- errors.push({
387
- path: `${taskPath}.continue_from`,
388
- message: `Task "${task.id}": continue_from "${task.continue_from}" 鈥?no such task found`,
389
- });
390
- } else if (resolved.kind === 'ambiguous') {
391
- errors.push({
392
- path: `${taskPath}.continue_from`,
393
- message: `Task "${task.id}": continue_from "${task.continue_from}" is ambiguous 鈥?multiple tracks have a task with this id. Use the fully-qualified form "trackId.${task.continue_from}".`,
394
- });
395
- } else if (
396
- !task.depends_on ||
397
- !task.depends_on.some((dep: string) => {
398
- const depResolved = resolveTaskRef(dep, track.id, index);
399
- return depResolved.kind === 'resolved' && depResolved.qid === resolved.qid;
400
- })
401
- ) {
402
- // H8: demote to a warning. dag.ts/buildDag inserts continue_from
403
- // as an implicit dependency at runtime, so the pipeline runs fine
404
- // without the explicit listing. Treat as a style hint rather than
405
- // blocking save / run, otherwise we frighten users with a red
406
- // "Configuration error" for code that would have run successfully.
407
- errors.push({
408
- path: `${taskPath}.continue_from`,
409
- message: `Task "${task.id}": continue_from "${task.continue_from}" should also be listed in depends_on for clarity (the runtime will add it implicitly).`,
410
- severity: 'warning',
411
- });
412
- }
413
- }
414
- }
415
- }
416
-
417
- // 鈹€鈹€ Cycle detection 鈹€鈹€
418
- errors.push(...detectCycles(config, index));
419
-
420
- return errors;
421
- }
422
-
423
- function validatePermissions(
424
- value: unknown,
425
- basePath: string,
426
- errors: ValidationError[],
427
- ): void {
428
- if (value === undefined) return;
429
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
430
- errors.push({
431
- path: basePath,
432
- message: 'permissions must be an object with read/write/execute booleans',
433
- });
434
- return;
435
- }
436
- const p = value as Record<string, unknown>;
437
- for (const field of PERMISSION_FIELDS) {
438
- const path = `${basePath}.${field}`;
439
- if (!(field in p)) {
440
- errors.push({ path, message: `permissions.${field} is required` });
441
- continue;
442
- }
443
- if (typeof p[field] !== 'boolean') {
444
- errors.push({ path, message: `permissions.${field} must be a boolean` });
445
- }
446
- }
447
- }
448
-
449
- const VALID_PORT_TYPES: ReadonlySet<PortType> = new Set([
450
- 'string',
451
- 'number',
452
- 'boolean',
453
- 'enum',
454
- 'json',
455
- ]);
456
-
457
- // Identifier pattern for port names. Deliberately narrower than task IDs 鈥?
458
- // port names appear in `{{inputs.<name>}}` templates where hyphens would
459
- // be parsed as subtraction, so we also forbid them here to keep the
460
- // template grammar unambiguous.
461
- const PORT_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
462
-
463
- function validateBindingMap(
464
- value: unknown,
465
- basePath: string,
466
- kind: 'inputs' | 'outputs',
467
- errors: ValidationError[],
468
- ): void {
469
- if (value === undefined) return;
470
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
471
- errors.push({ path: basePath, message: `task.${kind} must be an object map` });
472
- return;
473
- }
474
-
475
- const map = value as Record<string, unknown>;
476
- for (const [name, rawBinding] of Object.entries(map)) {
477
- const path = `${basePath}.${name}`;
478
- if (!PORT_NAME_RE.test(name)) {
479
- errors.push({
480
- path,
481
- message: `binding name "${name}" is invalid. Must match /^[A-Za-z_][A-Za-z0-9_]*$/.`,
482
- });
483
- }
484
- if (!rawBinding || typeof rawBinding !== 'object' || Array.isArray(rawBinding)) {
485
- errors.push({ path, message: `task.${kind}.${name} must be an object` });
486
- continue;
487
- }
488
- const binding = rawBinding as Record<string, unknown>;
489
- if ('from' in binding && typeof binding.from !== 'string') {
490
- errors.push({ path: `${path}.from`, message: `task.${kind}.${name}.from must be a string` });
491
- }
492
- if (kind === 'inputs' && 'required' in binding && typeof binding.required !== 'boolean') {
493
- errors.push({
494
- path: `${path}.required`,
495
- message: `task.inputs.${name}.required must be a boolean`,
496
- });
497
- }
498
- if ('type' in binding && binding.type !== undefined && !VALID_PORT_TYPES.has(binding.type as PortType)) {
499
- errors.push({
500
- path: `${path}.type`,
501
- message: `task.${kind}.${name}.type must be one of ${[...VALID_PORT_TYPES].join(', ')}`,
502
- });
503
- }
504
- if (binding.type === 'enum') {
505
- if (!Array.isArray(binding.enum) || binding.enum.length === 0) {
506
- errors.push({
507
- path: `${path}.enum`,
508
- message: `task.${kind}.${name}.enum must be a non-empty string array when type is enum`,
509
- });
510
- } else if (!binding.enum.every((v: unknown) => typeof v === 'string')) {
511
- errors.push({
512
- path: `${path}.enum`,
513
- message: `task.${kind}.${name}.enum values must all be strings`,
514
- });
515
- }
516
- }
517
- if (kind === 'outputs' && typeof binding.from === 'string') {
518
- const source = binding.from;
519
- const ok =
520
- source === 'stdout' ||
521
- source === 'stderr' ||
522
- source === 'normalizedOutput' ||
523
- /^json\.[A-Za-z_][A-Za-z0-9_]*$/.test(source);
524
- if (!ok) {
525
- errors.push({
526
- path: `${path}.from`,
527
- message: `task.outputs.${name}.from must be stdout, stderr, normalizedOutput, or json.<key>`,
528
- });
529
- }
530
- }
531
- }
532
- }
533
-
534
- function objectKeys(value: unknown): string[] {
535
- if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
536
- return Object.keys(value as Record<string, unknown>);
537
- }
538
-
539
- function validateInputBindingSources(
540
- task: RawTaskConfig,
541
- trackId: string,
542
- taskPath: string,
543
- index: TaskIndex,
544
- errors: ValidationError[],
545
- ): void {
546
- if (!task.inputs || typeof task.inputs !== 'object' || Array.isArray(task.inputs)) return;
547
- for (const [name, rawBinding] of Object.entries(task.inputs)) {
548
- if (!rawBinding || typeof rawBinding !== 'object' || Array.isArray(rawBinding)) continue;
549
- const source = (rawBinding as Record<string, unknown>).from;
550
- if (typeof source !== 'string') continue;
551
- const upstreamId = bindingSourceTaskId(source);
552
- if (!upstreamId) continue;
553
- const deps = task.depends_on ?? [];
554
- const isDirectDep = deps.some((dep) => {
555
- const resolved = resolveTaskRef(dep, trackId, index);
556
- return resolved.kind === 'resolved' && resolved.qid === upstreamId;
557
- });
558
- if (!isDirectDep) {
559
- errors.push({
560
- path: `${taskPath}.inputs.${name}.from`,
561
- message: `Task "${task.id}": input binding "${name}" from "${source}" references task "${upstreamId}" which is not a direct dependency (must be listed in depends_on)`,
562
- });
563
- }
564
- }
565
- }
566
-
567
- function bindingSourceTaskId(source: string): string | null {
568
- const outputMarker = '.outputs.';
569
- const outputIdx = source.lastIndexOf(outputMarker);
570
- if (outputIdx > 0) return source.slice(0, outputIdx);
571
- for (const field of ['stdout', 'stderr', 'normalizedOutput', 'exitCode']) {
572
- const suffix = `.${field}`;
573
- if (source.endsWith(suffix) && source.length > suffix.length) {
574
- return source.slice(0, -suffix.length);
575
- }
576
- }
577
- return null;
578
- }
579
-
580
- function validateTaskPorts(
581
- task: RawTaskConfig,
582
- trackId: string,
583
- taskPath: string,
584
- qidIndex: Map<string, QidEntry>,
585
- index: TaskIndex,
586
- errors: ValidationError[],
587
- ): void {
588
- const ports = task.ports;
589
- const isPromptTask = typeof task.prompt === 'string' && typeof task.command !== 'string';
590
- const isCommandTask = typeof task.command === 'string' && typeof task.prompt !== 'string';
591
-
592
- validateBindingMap(task.inputs, `${taskPath}.inputs`, 'inputs', errors);
593
- validateBindingMap(task.outputs, `${taskPath}.outputs`, 'outputs', errors);
594
- validateInputBindingSources(task, trackId, taskPath, index, errors);
595
-
596
- if (ports !== undefined) {
597
- errors.push({
598
- path: `${taskPath}.ports`,
599
- message:
600
- `Task "${task.id}": ports has been replaced by typed inputs/outputs. ` +
601
- `Move ports.inputs entries to task.inputs.<name> and ports.outputs entries to task.outputs.<name>.`,
602
- });
603
- return;
604
- }
605
-
606
- // Collect placeholder references 鈹€鈹€
607
- // `{{inputs.X}}` is valid in both prompt and command text. The set of
608
- // names a task may legally reference differs by task kind:
609
- // - Command Task: its own declared `inputs`
610
- // - Prompt Task: the union of direct-upstream Command outputs
611
- const referenced = new Set<string>();
612
- if (typeof task.prompt === 'string') {
613
- for (const n of extractInputReferences(task.prompt)) referenced.add(n);
614
- }
615
- if (typeof task.command === 'string') {
616
- for (const n of extractInputReferences(task.command)) referenced.add(n);
617
- }
618
-
619
- let availableInputs: Set<string>;
620
- if (isPromptTask) {
621
- availableInputs = collectUpstreamCommandOutputNames(task, trackId, qidIndex, index);
622
- for (const name of objectKeys(task.inputs)) availableInputs.add(name);
623
- } else {
624
- // Command Task (or the pathological both-keys case, which is caught
625
- // earlier as a separate error 鈥?tolerate it here).
626
- availableInputs = new Set<string>();
627
- for (const name of objectKeys(task.inputs)) availableInputs.add(name);
628
- }
629
-
630
- for (const name of referenced) {
631
- if (!availableInputs.has(name)) {
632
- const hint = isPromptTask
633
- ? `no upstream Command task exports an output named "${name}"`
634
- : `no such input is declared`;
635
- errors.push({
636
- path: taskPath,
637
- message: `Task "${task.id}": references "{{inputs.${name}}}" but ${hint}`,
638
- });
639
- }
640
- }
641
-
642
- // Prompt-task inferred-port conflict checks 鈹€鈹€
643
- //
644
- // Static counterparts to the runtime checks `inferPromptPorts` runs.
645
- // These surface problems at author-time in the editor so the user
646
- // fixes them before a run, rather than hitting a "blocked" task.
647
- if (isPromptTask) {
648
- validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex, index, errors);
649
- }
650
- }
651
-
652
- /**
653
- * Walk the direct-upstream Commands of a Prompt Task and collect every
654
- * output port name they export. Prompt upstreams contribute nothing 鈥?
655
- * they pass free text via continue_from, not structured ports 鈥?so we
656
- * skip them. This mirrors exactly what the engine does at runtime in
657
- * `inferPromptPorts`, keeping the editor and runtime views aligned.
658
- */
659
- function collectUpstreamCommandOutputNames(
660
- task: RawTaskConfig,
661
- trackId: string,
662
- qidIndex: Map<string, QidEntry>,
663
- index: TaskIndex,
664
- ): Set<string> {
665
- const names = new Set<string>();
666
- for (const dep of task.depends_on ?? []) {
667
- const r = resolveTaskRef(dep, trackId, index);
668
- if (r.kind !== 'resolved') continue;
669
- const entry = qidIndex.get(r.qid);
670
- if (!entry) continue;
671
- // Only Command tasks contribute 鈥?Prompt upstreams pass free text.
672
- if (typeof entry.task.command !== 'string') continue;
673
- const outputs = entry.task.outputs;
674
- if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) continue;
675
- for (const name of Object.keys(outputs)) {
676
- names.add(name);
677
- }
678
- }
679
- return names;
680
- }
681
-
682
- /**
683
- * Detect the two kinds of collision that would block a Prompt Task at
684
- * runtime 鈥?report them at validate-time so the editor lights them up
685
- * before a run is attempted.
686
- *
687
- * 1. Input collision: two direct-upstream Commands both export an
688
- * output with the same name. Command鈫扖ommand would let the
689
- * downstream disambiguate with `from:`; Prompt tasks have no port
690
- * declarations and therefore no escape hatch.
691
- * 2. Output collision: two direct-downstream Commands declare inputs
692
- * with the same name but incompatible shapes (different type, or
693
- * different enum sets). A single LLM emission cannot satisfy both.
694
- */
695
- function validateInferredPromptPortConflicts(
696
- task: RawTaskConfig,
697
- trackId: string,
698
- taskPath: string,
699
- qidIndex: Map<string, QidEntry>,
700
- index: TaskIndex,
701
- errors: ValidationError[],
702
- ): void {
703
- // 鈹€鈹€鈹€ Input collision 鈹€鈹€
704
- const producersByName = new Map<string, string[]>();
705
- for (const dep of task.depends_on ?? []) {
706
- const r = resolveTaskRef(dep, trackId, index);
707
- if (r.kind !== 'resolved') continue;
708
- const entry = qidIndex.get(r.qid);
709
- if (!entry || typeof entry.task.command !== 'string') continue;
710
- const outputs = entry.task.outputs;
711
- if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) continue;
712
- for (const name of Object.keys(outputs)) {
713
- const list = producersByName.get(name) ?? [];
714
- list.push(r.qid);
715
- producersByName.set(name, list);
716
- }
717
- }
718
- for (const [name, producers] of producersByName) {
719
- if (producers.length > 1) {
720
- errors.push({
721
- path: taskPath,
722
- message:
723
- `Task "${task.id}": upstream Commands ${producers.join(', ')} all export ` +
724
- `"${name}" 鈥?prompt tasks cannot disambiguate (no "from:" binding available). ` +
725
- `Rename the output on one of the upstream Commands.`,
726
- });
727
- }
728
- }
729
-
730
- // 鈹€鈹€鈹€ Output collision 鈹€鈹€
731
- //
732
- // Walk every task in the pipeline once and check whether it depends on
733
- // us. We reuse the shared qidIndex + TaskIndex for the lookup; small
734
- // pipelines stay O(tasks), which is fine for validate-raw (it already
735
- // O(tasks) elsewhere).
736
- const taskQid = qualifyTaskId(trackId, task.id);
737
- const consumerShapeByName = new Map<
738
- string,
739
- { readonly shape: string; readonly firstConsumer: string }
740
- >();
741
- const reported = new Set<string>();
742
- for (const [downstreamQid, entry] of qidIndex) {
743
- if (downstreamQid === taskQid) continue;
744
- if (typeof entry.task.command !== 'string') continue; // only downstream Commands contribute
745
- const deps = entry.task.depends_on ?? [];
746
- let dependsOnUs = false;
747
- for (const d of deps) {
748
- const r = resolveTaskRef(d, entry.track.id, index);
749
- if (r.kind === 'resolved' && r.qid === taskQid) {
750
- dependsOnUs = true;
751
- break;
752
- }
753
- }
754
- if (!dependsOnUs) continue;
755
- const inputs = entry.task.inputs;
756
- if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) continue;
757
- for (const [inputName, binding] of Object.entries(inputs)) {
758
- if (!binding || typeof binding !== 'object' || Array.isArray(binding)) continue;
759
- const shape = bindingShapeKey(binding as { type?: PortType; enum?: readonly string[] });
760
- const prior = consumerShapeByName.get(inputName);
761
- if (!prior) {
762
- consumerShapeByName.set(inputName, { shape, firstConsumer: downstreamQid });
763
- continue;
764
- }
765
- if (prior.shape !== shape && !reported.has(inputName)) {
766
- reported.add(inputName);
767
- errors.push({
768
- path: taskPath,
769
- message:
770
- `Task "${task.id}": downstream Commands ${prior.firstConsumer} and ` +
771
- `${downstreamQid} disagree on the shape of inferred output "${inputName}" 鈥?` +
772
- `a single LLM emission cannot satisfy both. Rename on one side.`,
773
- });
774
- }
775
- }
776
- }
777
- }
778
-
779
- function bindingShapeKey(port: { type?: PortType; enum?: readonly string[] }): string {
780
- if ((port.type ?? 'json') !== 'enum') return String(port.type ?? 'json');
781
- const enums = Array.isArray(port.enum) ? [...port.enum].sort().join('|') : '';
782
- return `enum:${enums}`;
783
- }
784
-
785
- function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationError[] {
786
- // Build adjacency: qualifiedId 鈫?[resolved dep qualifiedIds]
787
- const adj = new Map<string, string[]>();
788
-
789
- for (const track of config.tracks) {
790
- if (!track.id) continue;
791
- if (!Array.isArray(track.tasks)) continue;
792
- for (const task of track.tasks ?? []) {
793
- if (!task.id) continue;
794
- const qid = qualifyTaskId(track.id, task.id);
795
- const deps: string[] = [];
796
- for (const dep of task.depends_on ?? []) {
797
- const resolved = resolveTaskRef(dep, track.id, index);
798
- if (resolved.kind === 'resolved') deps.push(resolved.qid);
799
- }
800
- if (task.continue_from) {
801
- const resolved = resolveTaskRef(task.continue_from, track.id, index);
802
- if (resolved.kind === 'resolved' && !deps.includes(resolved.qid)) deps.push(resolved.qid);
803
- }
804
- adj.set(qid, deps);
805
- }
806
- }
807
-
808
- const errors: ValidationError[] = [];
809
- const visited = new Set<string>();
810
- const inStack = new Set<string>();
811
- // Deduplicate cycles: the same cycle can be discovered from multiple entry points.
812
- // Canonical key = sorted node list joined 鈥?order-independent fingerprint.
813
- const seenCycles = new Set<string>();
814
-
815
- // Use a mutable path array instead of copying at each level (O(n) vs O(n^2)).
816
- const pathStack: string[] = [];
817
-
818
- function dfs(id: string): void {
819
- if (inStack.has(id)) {
820
- const cycleStart = pathStack.indexOf(id);
821
- // Unique nodes in the cycle (without repeating the start node) for dedup.
822
- // Previously the duplicate start node caused different sorted keys when
823
- // the same cycle was discovered from different entry points.
824
- const uniqueNodes = pathStack.slice(cycleStart);
825
- const key = [...uniqueNodes].sort().join(',');
826
- if (!seenCycles.has(key)) {
827
- seenCycles.add(key);
828
- const display = [...uniqueNodes, id]; // include start for readable display
829
- errors.push({
830
- path: 'tracks',
831
- message: `Circular dependency detected: ${display.join(' 鈫?')}`,
832
- });
833
- }
834
- return;
835
- }
836
- if (visited.has(id)) return;
837
- visited.add(id);
838
- inStack.add(id);
839
- pathStack.push(id);
840
- for (const dep of adj.get(id) ?? []) {
841
- dfs(dep);
842
- }
843
- pathStack.pop();
844
- inStack.delete(id);
845
- }
846
-
847
- for (const id of adj.keys()) {
848
- if (!visited.has(id)) dfs(id);
849
- }
850
-
851
- return errors;
852
- }