@tagma/sdk 0.2.7 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagma/sdk",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,6 +34,17 @@ export const OutputCheckCompletion: CompletionPlugin = {
34
34
  const controller = new AbortController();
35
35
  const timer = setTimeout(() => controller.abort(), timeoutMs);
36
36
 
37
+ // Wire pipeline abort signal into the check process so external abort
38
+ // terminates the child instead of leaving it running undetected.
39
+ const onAbort = () => controller.abort();
40
+ if (ctx.signal) {
41
+ if (ctx.signal.aborted) {
42
+ controller.abort();
43
+ } else {
44
+ ctx.signal.addEventListener('abort', onAbort, { once: true });
45
+ }
46
+ }
47
+
37
48
  const proc = Bun.spawn(shellArgs(checkCmd) as string[], {
38
49
  cwd: ctx.workDir,
39
50
  stdin: 'pipe',
@@ -69,6 +80,7 @@ export const OutputCheckCompletion: CompletionPlugin = {
69
80
  return exitCode === 0;
70
81
  } finally {
71
82
  clearTimeout(timer);
83
+ if (ctx.signal) ctx.signal.removeEventListener('abort', onAbort);
72
84
  }
73
85
  },
74
86
  };
package/src/dag.ts CHANGED
@@ -118,8 +118,10 @@ export function buildDag(config: PipelineConfig): Dag {
118
118
  }
119
119
 
120
120
  const sorted: string[] = [];
121
- while (queue.length > 0) {
122
- const current = queue.shift()!;
121
+ // Use an index pointer instead of shift() to avoid O(n) per dequeue.
122
+ let qi = 0;
123
+ while (qi < queue.length) {
124
+ const current = queue[qi++]!;
123
125
  sorted.push(current);
124
126
  for (const child of adjacency.get(current)!) {
125
127
  const newDegree = inDegree.get(child)! - 1;
@@ -129,8 +131,13 @@ export function buildDag(config: PipelineConfig): Dag {
129
131
  }
130
132
 
131
133
  if (sorted.length !== nodes.size) {
132
- const remaining = [...nodes.keys()].filter(id => !sorted.includes(id));
133
- throw new Error(`Circular dependency detected involving tasks: ${remaining.join(', ')}`);
134
+ // Only report nodes that are actually part of cycles (in-degree > 0
135
+ // after Kahn's algorithm), not their downstream dependents.
136
+ const sortedSet = new Set(sorted);
137
+ const cycleMembers = [...nodes.keys()].filter(id =>
138
+ !sortedSet.has(id) && (inDegree.get(id) ?? 0) > 0
139
+ );
140
+ throw new Error(`Circular dependency detected involving tasks: ${cycleMembers.join(', ')}`);
134
141
  }
135
142
 
136
143
  return { nodes, sorted };
package/src/engine.ts CHANGED
@@ -88,13 +88,23 @@ function preflight(config: PipelineConfig, dag: Dag): void {
88
88
  }
89
89
 
90
90
  function resolveRefInDag(dag: Dag, ref: string, fromTrackId: string): string | null {
91
+ // Already fully qualified
91
92
  if (dag.nodes.has(ref)) return ref;
93
+ // Same-track match (preferred)
92
94
  const sameTrack = `${fromTrackId}.${ref}`;
93
95
  if (dag.nodes.has(sameTrack)) return sameTrack;
96
+ // Cross-track bare name lookup — must be unambiguous (aligned with buildDag's resolveRef)
97
+ let match: string | null = null;
94
98
  for (const [id] of dag.nodes) {
95
- if (id.endsWith(`.${ref}`)) return id;
99
+ if (id.endsWith(`.${ref}`)) {
100
+ if (match !== null) {
101
+ // Ambiguous: multiple tasks share the bare name across tracks
102
+ return null;
103
+ }
104
+ match = id;
105
+ }
96
106
  }
97
- return null;
107
+ return match;
98
108
  }
99
109
 
100
110
  // ═══ Engine ═══
@@ -187,6 +197,9 @@ export async function runPipeline(
187
197
  text: record.text,
188
198
  });
189
199
  });
200
+
201
+ try {
202
+
190
203
  log.info('[pipeline]', `start "${config.name}" run_id=${runId}`);
191
204
 
192
205
  // File-only: dump the resolved pipeline shape + DAG topology for post-mortem.
@@ -220,8 +233,6 @@ export async function runPipeline(
220
233
  });
221
234
  }
222
235
 
223
- try {
224
-
225
236
  // Pipeline start hook (gate)
226
237
  const startHook = await executeHook(
227
238
  config.hooks, 'pipeline_start', buildPipelineStartContext(pipelineInfo), workDir,
@@ -274,15 +285,15 @@ export async function runPipeline(
274
285
  });
275
286
 
276
287
  // Wire external cancel signal into the internal abort controller.
288
+ const externalAbortHandler = () => {
289
+ pipelineAborted = true;
290
+ abortController.abort();
291
+ };
277
292
  if (options.signal) {
278
293
  if (options.signal.aborted) {
279
- pipelineAborted = true;
280
- abortController.abort();
294
+ externalAbortHandler();
281
295
  } else {
282
- options.signal.addEventListener('abort', () => {
283
- pipelineAborted = true;
284
- abortController.abort();
285
- }, { once: true });
296
+ options.signal.addEventListener('abort', externalAbortHandler, { once: true });
286
297
  }
287
298
  }
288
299
 
@@ -367,7 +378,7 @@ export async function runPipeline(
367
378
 
368
379
  async function fireHook(taskId: string, event: 'task_success' | 'task_failure'): Promise<void> {
369
380
  await executeHook(config.hooks, event,
370
- buildTaskContext(event, pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir);
381
+ buildTaskContext(event, pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir, abortController.signal);
371
382
  }
372
383
 
373
384
  // ── Process a single task ──
@@ -429,7 +440,7 @@ export async function runPipeline(
429
440
 
430
441
  // 3. task_start hook (gate)
431
442
  const hookResult = await executeHook(config.hooks, 'task_start',
432
- buildTaskContext('task_start', pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir);
443
+ buildTaskContext('task_start', pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir, abortController.signal);
433
444
  if (hookResult.exitCode !== 0 || config.hooks?.task_start) {
434
445
  log.debug(`[task:${taskId}]`,
435
446
  `task_start hook exit=${hookResult.exitCode} allowed=${hookResult.allowed}`);
@@ -521,7 +532,7 @@ export async function runPipeline(
521
532
  terminalStatus = 'failed';
522
533
  } else if (task.completion) {
523
534
  const plugin = getHandler<CompletionPlugin>('completions', task.completion.type);
524
- const completionCtx = { workDir: task.cwd ?? workDir };
535
+ const completionCtx = { workDir: task.cwd ?? workDir, signal: abortController.signal };
525
536
  const passed = await plugin.check(task.completion as Record<string, unknown>, result, completionCtx);
526
537
  terminalStatus = passed ? 'success' : 'failed';
527
538
  } else {
@@ -670,6 +681,11 @@ export async function runPipeline(
670
681
  }
671
682
  } finally {
672
683
  if (pipelineTimer) clearTimeout(pipelineTimer);
684
+ // Clean up the external abort signal listener to prevent dead references
685
+ // accumulating on long-lived shared AbortControllers.
686
+ if (options.signal) {
687
+ options.signal.removeEventListener('abort', externalAbortHandler);
688
+ }
673
689
  // Safety net: drain any approvals still pending at shutdown (e.g. crash path).
674
690
  if (approvalGateway.pending().length > 0) {
675
691
  approvalGateway.abortAll('pipeline finished');
@@ -720,10 +736,10 @@ export async function runPipeline(
720
736
  log.quiet(` ${state.status.padEnd(8)} ${id} (exit=${exit}, ${dur})`);
721
737
  }
722
738
 
723
- console.log(`\n[Pipeline "${config.name}"] completed`);
724
- console.log(` Total: ${summary.total} | Success: ${summary.success} | Failed: ${summary.failed} | Skipped: ${summary.skipped} | Timeout: ${summary.timeout} | Blocked: ${summary.blocked}`);
725
- console.log(` Duration: ${(durationMs / 1000).toFixed(1)}s`);
726
- console.log(` Log: ${log.path}`);
739
+ log.info('[pipeline]', `completed "${config.name}"`);
740
+ log.info('[pipeline]', `Total: ${summary.total} | Success: ${summary.success} | Failed: ${summary.failed} | Skipped: ${summary.skipped} | Timeout: ${summary.timeout} | Blocked: ${summary.blocked}`);
741
+ log.info('[pipeline]', `Duration: ${(durationMs / 1000).toFixed(1)}s`);
742
+ log.info('[pipeline]', `Log: ${log.path}`);
727
743
 
728
744
  emit({ type: 'pipeline_end', runId, success: allSuccess });
729
745
  return { success: allSuccess, runId, logPath: log.path, summary, states: freezeStates(states) };
package/src/hooks.ts CHANGED
@@ -18,42 +18,71 @@ function normalizeCommands(cmd: HookCommand | undefined): readonly string[] {
18
18
  return cmd;
19
19
  }
20
20
 
21
- async function runSingleHook(command: string, context: unknown, cwd?: string): Promise<number> {
21
+ const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
22
+
23
+ async function runSingleHook(
24
+ command: string,
25
+ context: unknown,
26
+ cwd?: string,
27
+ signal?: AbortSignal,
28
+ timeoutMs: number = DEFAULT_HOOK_TIMEOUT_MS,
29
+ ): Promise<number> {
22
30
  const jsonInput = JSON.stringify(context, null, 2);
23
31
 
24
- const proc = Bun.spawn(shellArgs(command) as string[], {
25
- stdin: 'pipe',
26
- stdout: 'pipe',
27
- stderr: 'pipe',
28
- ...(cwd ? { cwd } : {}),
29
- });
30
-
31
- if (proc.stdin) {
32
- try {
33
- proc.stdin.write(jsonInput);
34
- proc.stdin.end();
35
- } catch {
36
- // Process may exit before reading stdin (e.g. `exit 1`), ignore EPIPE
32
+ const controller = new AbortController();
33
+ const timer = timeoutMs > 0
34
+ ? setTimeout(() => controller.abort(), timeoutMs)
35
+ : null;
36
+
37
+ // Wire pipeline abort signal into hook process
38
+ const onAbort = () => controller.abort();
39
+ if (signal) {
40
+ if (signal.aborted) {
41
+ controller.abort();
42
+ } else {
43
+ signal.addEventListener('abort', onAbort, { once: true });
37
44
  }
38
45
  }
39
46
 
40
- // Consume stdout and stderr concurrently with waiting for exit.
41
- // Sequential reads after proc.exited risk a pipe-buffer deadlock when
42
- // hook output exceeds the ~64 KB kernel buffer.
43
- const [exitCode, stdout, stderr] = await Promise.all([
44
- proc.exited,
45
- new Response(proc.stdout).text(),
46
- new Response(proc.stderr).text(),
47
- ]);
48
-
49
- if (stdout.trim()) {
50
- console.log(`[hook: ${command}] stdout: ${stdout.trim()}`);
51
- }
52
- if (stderr.trim()) {
53
- console.error(`[hook: ${command}] stderr: ${stderr.trim()}`);
54
- }
47
+ try {
48
+ const proc = Bun.spawn(shellArgs(command) as string[], {
49
+ stdin: 'pipe',
50
+ stdout: 'pipe',
51
+ stderr: 'pipe',
52
+ signal: controller.signal,
53
+ ...(cwd ? { cwd } : {}),
54
+ });
55
+
56
+ if (proc.stdin) {
57
+ try {
58
+ proc.stdin.write(jsonInput);
59
+ proc.stdin.end();
60
+ } catch {
61
+ // Process may exit before reading stdin (e.g. `exit 1`), ignore EPIPE
62
+ }
63
+ }
55
64
 
56
- return exitCode;
65
+ // Consume stdout and stderr concurrently with waiting for exit.
66
+ // Sequential reads after proc.exited risk a pipe-buffer deadlock when
67
+ // hook output exceeds the ~64 KB kernel buffer.
68
+ const [exitCode, stdout, stderr] = await Promise.all([
69
+ proc.exited,
70
+ new Response(proc.stdout).text(),
71
+ new Response(proc.stderr).text(),
72
+ ]);
73
+
74
+ if (stdout.trim()) {
75
+ console.log(`[hook: ${command}] stdout: ${stdout.trim()}`);
76
+ }
77
+ if (stderr.trim()) {
78
+ console.error(`[hook: ${command}] stderr: ${stderr.trim()}`);
79
+ }
80
+
81
+ return exitCode;
82
+ } finally {
83
+ if (timer) clearTimeout(timer);
84
+ if (signal) signal.removeEventListener('abort', onAbort);
85
+ }
57
86
  }
58
87
 
59
88
  export async function executeHook(
@@ -61,6 +90,7 @@ export async function executeHook(
61
90
  event: HookEvent,
62
91
  context: unknown,
63
92
  workDir?: string,
93
+ signal?: AbortSignal,
64
94
  ): Promise<HookResult> {
65
95
  if (!hooks) return { allowed: true, exitCode: 0 };
66
96
 
@@ -70,7 +100,7 @@ export async function executeHook(
70
100
  const isGate = GATE_HOOKS.has(event);
71
101
 
72
102
  for (const cmd of commands) {
73
- const exitCode = await runSingleHook(cmd, context, workDir);
103
+ const exitCode = await runSingleHook(cmd, context, workDir, signal);
74
104
 
75
105
  if (isGate && exitCode === 1) {
76
106
  // Only exit code 1 has gate semantics (block execution)
@@ -61,6 +61,15 @@ export class PipelineRunner {
61
61
  start(): Promise<EngineResult> {
62
62
  if (this._result) return this._result;
63
63
 
64
+ // Guard: if abort() was called before start(), the signal is already
65
+ // aborted. Create a fresh controller so the pipeline doesn't terminate
66
+ // immediately. If users truly want pre-abort semantics, they call
67
+ // abort() after start().
68
+ if (this._abortController.signal.aborted) {
69
+ this._abortController = new AbortController();
70
+ this._status = 'idle';
71
+ }
72
+
64
73
  this._status = 'running';
65
74
  this._result = runPipeline(this.config, this.workDir, {
66
75
  ...this.opts,
package/src/registry.ts CHANGED
@@ -37,8 +37,20 @@ export function hasHandler(category: PluginCategory, type: string): boolean {
37
37
  return registries[category].has(type);
38
38
  }
39
39
 
40
+ // Plugin name must be a scoped npm package or a tagma-prefixed package.
41
+ // Reject absolute/relative paths and suspicious patterns to prevent
42
+ // arbitrary code execution via crafted YAML configs.
43
+ const PLUGIN_NAME_RE = /^(@[a-z0-9-]+\/[a-z0-9._-]+|tagma-plugin-[a-z0-9._-]+)$/;
44
+
40
45
  export async function loadPlugins(pluginNames: readonly string[]): Promise<void> {
41
46
  for (const name of pluginNames) {
47
+ if (!PLUGIN_NAME_RE.test(name)) {
48
+ throw new Error(
49
+ `Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
50
+ `(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
51
+ `Relative/absolute paths are not allowed.`
52
+ );
53
+ }
42
54
  const mod = await import(name);
43
55
  if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
44
56
  throw new Error(
package/src/runner.ts CHANGED
@@ -49,8 +49,18 @@ export interface RunOptions {
49
49
  * Returns the original name if resolution fails; Bun will raise the same
50
50
  * ENOENT it would have otherwise.
51
51
  */
52
+ const RESOLVED_EXE_CACHE_MAX = 128;
52
53
  const resolvedExeCache = new Map<string, string | null>();
53
54
 
55
+ /** Evict the oldest entry when the cache is at capacity. */
56
+ function evictIfFull(): void {
57
+ if (resolvedExeCache.size >= RESOLVED_EXE_CACHE_MAX) {
58
+ // Map iteration order is insertion order — delete the first (oldest) key.
59
+ const oldest = resolvedExeCache.keys().next().value;
60
+ if (oldest !== undefined) resolvedExeCache.delete(oldest);
61
+ }
62
+ }
63
+
54
64
  function resolveWindowsExe(
55
65
  args: readonly string[],
56
66
  envPath: string,
@@ -80,11 +90,13 @@ function resolveWindowsExe(
80
90
  for (const ext of exts) {
81
91
  const candidate = join(dir, cmd + ext);
82
92
  if (existsSync(candidate)) {
93
+ evictIfFull();
83
94
  resolvedExeCache.set(cacheKey, candidate);
84
95
  return [candidate, ...args.slice(1)];
85
96
  }
86
97
  }
87
98
  }
99
+ evictIfFull();
88
100
  resolvedExeCache.set(cacheKey, null);
89
101
  return args;
90
102
  }
package/src/schema.ts CHANGED
@@ -198,10 +198,15 @@ function expandTemplateTask(
198
198
  newTask.continue_from = `${instanceId}.${task.continue_from}`;
199
199
  }
200
200
 
201
- // Rewrite output path to instance namespace
201
+ // Rewrite output path to instance namespace so parallel template
202
+ // instances don't collide on the same file. Handles any relative path
203
+ // (e.g. ./tmp/foo, ./output/bar, ./build/result.json) by injecting
204
+ // the instanceId as the first directory component after `./`.
202
205
  if (task.output) {
203
206
  const original = interpolate(task.output);
204
- newTask.output = original.replace('./tmp/', `./tmp/${instanceId}/`);
207
+ newTask.output = original.startsWith('./')
208
+ ? `./${instanceId}/${original.slice(2)}`
209
+ : `${instanceId}/${original}`;
205
210
  }
206
211
 
207
212
  return newTask as unknown as RawTaskConfig;
@@ -1,5 +1,6 @@
1
1
  import { watch } from 'chokidar';
2
2
  import { resolve, dirname } from 'path';
3
+ import { mkdir } from 'fs/promises';
3
4
  import type { TriggerPlugin, TriggerContext } from '../types';
4
5
  import { parseDuration, validatePath } from '../utils';
5
6
 
@@ -35,7 +36,7 @@ export const FileTrigger: TriggerPlugin = {
35
36
  const safePath = validatePath(filePath, ctx.workDir);
36
37
  const timeoutMs = config.timeout != null ? parseDuration(String(config.timeout)) : 0;
37
38
 
38
- return new Promise((resolve_p, reject) => {
39
+ return new Promise(async (resolve_p, reject) => {
39
40
  if (ctx.signal.aborted) {
40
41
  reject(new Error('Pipeline aborted'));
41
42
  return;
@@ -44,7 +45,13 @@ export const FileTrigger: TriggerPlugin = {
44
45
  let settled = false;
45
46
  let timer: ReturnType<typeof setTimeout> | null = null;
46
47
 
48
+ // Ensure the parent directory exists so the watcher doesn't fail
49
+ // with ENOENT for nested paths like `build/output/result.json`.
47
50
  const dir = dirname(safePath);
51
+ try {
52
+ await mkdir(dir, { recursive: true });
53
+ } catch { /* best effort — dir may already exist */ }
54
+
48
55
  const watcher = watch(dir, {
49
56
  ignoreInitial: true,
50
57
  depth: 0,
@@ -41,19 +41,26 @@ export const ManualTrigger: TriggerPlugin = {
41
41
  // so instead we race against an abort promise and let engine status logic
42
42
  // fall back to pipelineAborted → skipped. abortAll() on gateway still runs
43
43
  // from engine shutdown path to clean up any truly-pending entries.
44
+ const onAbort = () => {};
44
45
  const abortPromise = new Promise<never>((_, reject) => {
45
46
  if (ctx.signal.aborted) {
46
47
  reject(new Error('Pipeline aborted'));
47
48
  return;
48
49
  }
49
- ctx.signal.addEventListener(
50
- 'abort',
51
- () => reject(new Error('Pipeline aborted')),
52
- { once: true },
53
- );
50
+ const handler = () => reject(new Error('Pipeline aborted'));
51
+ // Store reference so we can remove it after the race settles.
52
+ (onAbort as { handler?: () => void }).handler = handler;
53
+ ctx.signal.addEventListener('abort', handler, { once: true });
54
54
  });
55
55
 
56
- const decision = await Promise.race([decisionPromise, abortPromise]);
56
+ let decision: Awaited<typeof decisionPromise>;
57
+ try {
58
+ decision = await Promise.race([decisionPromise, abortPromise]);
59
+ } finally {
60
+ // Clean up the abort listener to prevent leaking on normal completion.
61
+ const handler = (onAbort as { handler?: () => void }).handler;
62
+ if (handler) ctx.signal.removeEventListener('abort', handler);
63
+ }
57
64
 
58
65
  switch (decision.outcome) {
59
66
  case 'approved':
package/src/utils.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { resolve, relative } from 'path';
2
2
  import { randomBytes } from 'crypto';
3
3
 
4
- const DURATION_RE = /^(\d+(?:\.\d+)?)\s*(s|m|h)$/;
4
+ const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
5
5
 
6
6
  export function parseDuration(input: string): number {
7
7
  const match = DURATION_RE.exec(input.trim());
8
8
  if (!match) {
9
- throw new Error(`Invalid duration format: "${input}". Expected format: <number>(s|m|h)`);
9
+ throw new Error(`Invalid duration format: "${input}". Expected format: <number>(s|m|h|d)`);
10
10
  }
11
11
  const value = parseFloat(match[1]);
12
12
  const unit = match[2];
@@ -14,6 +14,7 @@ export function parseDuration(input: string): number {
14
14
  case 's': return value * 1000;
15
15
  case 'm': return value * 60_000;
16
16
  case 'h': return value * 3_600_000;
17
+ case 'd': return value * 86_400_000;
17
18
  default: throw new Error(`Unknown duration unit: "${unit}"`);
18
19
  }
19
20
  }
@@ -136,8 +137,13 @@ export function shellArgs(command: string): readonly string[] {
136
137
  /** Quote a single argument for inclusion in a shell command string. */
137
138
  function quoteArg(arg: string): string {
138
139
  if (!/[\s"'\\<>|&;`$!^%]/.test(arg)) return arg;
139
- // Double-quote and escape embedded double quotes + backslashes
140
- return '"' + arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
140
+ if (IS_WINDOWS) {
141
+ // On Windows (cmd.exe), double-quote and escape embedded quotes + backslashes
142
+ return '"' + arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
143
+ }
144
+ // On Unix, use single quotes to prevent $variable expansion.
145
+ // Escape embedded single quotes via the '\'' idiom.
146
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
141
147
  }
142
148
 
143
149
  /**
@@ -201,10 +201,13 @@ function detectCycles(
201
201
  // Canonical key = sorted node list joined — order-independent fingerprint.
202
202
  const seenCycles = new Set<string>();
203
203
 
204
- function dfs(id: string, path: string[]): void {
204
+ // Use a mutable path array instead of copying at each level (O(n) vs O(n^2)).
205
+ const pathStack: string[] = [];
206
+
207
+ function dfs(id: string): void {
205
208
  if (inStack.has(id)) {
206
- const cycleStart = path.indexOf(id);
207
- const cycleNodes = [...path.slice(cycleStart), id];
209
+ const cycleStart = pathStack.indexOf(id);
210
+ const cycleNodes = [...pathStack.slice(cycleStart), id];
208
211
  const key = [...cycleNodes].sort().join(',');
209
212
  if (!seenCycles.has(key)) {
210
213
  seenCycles.add(key);
@@ -215,14 +218,16 @@ function detectCycles(
215
218
  if (visited.has(id)) return;
216
219
  visited.add(id);
217
220
  inStack.add(id);
221
+ pathStack.push(id);
218
222
  for (const dep of adj.get(id) ?? []) {
219
- dfs(dep, [...path, id]);
223
+ dfs(dep);
220
224
  }
225
+ pathStack.pop();
221
226
  inStack.delete(id);
222
227
  }
223
228
 
224
229
  for (const id of adj.keys()) {
225
- if (!visited.has(id)) dfs(id, []);
230
+ if (!visited.has(id)) dfs(id);
226
231
  }
227
232
 
228
233
  return errors;