@lincyaw/kernel 0.0.0-stage → 1.0.0-rc.0

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/src/runner.py ADDED
@@ -0,0 +1,454 @@
1
+ # The Python runner of Presmium (docs/architecture.md 9.2), published by the kernel as
2
+ # `@lincyaw/kernel/runner.py` and shared by the plugins that run Python: the execution plugin
3
+ # loads it into Pyodide in the browser and runs it with the local python3 at compile time, the
4
+ # local environment plugin runs it in the container. The kernel itself never reads it.
5
+ #
6
+ # A run gives the parameters as variables (read back by `_pm_arg`: literals keep their type), the
7
+ # request's stdin to the program and its files in the working directory. Its result is JSON: the
8
+ # text streams `stdout` and `stderr`, the exit code, and the rich outputs in nbformat form
9
+ # (`outputs`: the streams in order, what `display()` shows, matplotlib figures, the value of a
10
+ # last expression as Jupyter shows it, the error), docs/ir.md 6.1.
11
+ #
12
+ # Without a session every run gets fresh globals, and what it changes in the interpreter is undone
13
+ # afterwards (_PmIsolation), so one run in the browser's long-lived interpreter cannot affect the
14
+ # next. The runs of one session share their globals and working directory until `_pm_end`.
15
+ #
16
+ # At compile time the process reads its input from `.presmium/request.json` in the run directory,
17
+ # its working directory, and writes the result of run i to `.presmium/result-<i>.json` there
18
+ # (`_pm_main`): no standard input or extra file descriptor, which a process sandbox does not pass on.
19
+ import ast
20
+ import base64
21
+ import builtins
22
+ import contextlib
23
+ import importlib.util
24
+ import io
25
+ import json
26
+ import linecache
27
+ import os
28
+ import shutil
29
+ import sys
30
+ import tempfile
31
+ import traceback
32
+ import types
33
+
34
+ _PM_FILE = '<presmium>'
35
+ # Session name -> {'scope': the globals, 'dir': the working directory}.
36
+ _PM_SESSIONS = {}
37
+ # The outputs of the run under way, which display() and plt.show() add to.
38
+ _PM_CURRENT = None
39
+
40
+
41
+ class _PmStop(BaseException):
42
+ """Raised by the tracer when the event limit is reached."""
43
+
44
+
45
+ class _PmOutputs:
46
+ """The outputs of one run in order (nbformat), with the text streams also kept apart."""
47
+
48
+ def __init__(self):
49
+ self.items = []
50
+ self.text = {'stdout': [], 'stderr': []}
51
+
52
+ def stream(self, name, text):
53
+ if not text:
54
+ return
55
+ self.text[name].append(text)
56
+ last = self.items[-1] if self.items else None
57
+ if last is not None and last['output_type'] == 'stream' and last['name'] == name:
58
+ last['text'] += text
59
+ else:
60
+ self.items.append({'output_type': 'stream', 'name': name, 'text': text})
61
+
62
+ def display(self, data, kind='display_data'):
63
+ self.items.append({'output_type': kind, 'data': data, 'metadata': {}})
64
+
65
+ def error(self, etype, value, tb):
66
+ text = ''.join(traceback.format_exception(etype, value, tb))
67
+ self.text['stderr'].append(text)
68
+ self.items.append({'output_type': 'error', 'ename': etype.__name__, 'evalue': str(value), 'traceback': text.rstrip('\n').split('\n')})
69
+
70
+
71
+ class _PmStream(io.TextIOBase):
72
+ """sys.stdout or sys.stderr of a run: what the program writes becomes a stream output."""
73
+
74
+ def __init__(self, outputs, name):
75
+ super().__init__()
76
+ self._outputs = outputs
77
+ self._name = name
78
+
79
+ @property
80
+ def encoding(self):
81
+ return 'utf-8'
82
+
83
+ def writable(self):
84
+ return True
85
+
86
+ def write(self, text):
87
+ self._outputs.stream(self._name, str(text))
88
+ return len(text)
89
+
90
+
91
+ _PM_REPRS = [
92
+ ('text/html', '_repr_html_'),
93
+ ('text/markdown', '_repr_markdown_'),
94
+ ('text/latex', '_repr_latex_'),
95
+ ('image/svg+xml', '_repr_svg_'),
96
+ ('image/png', '_repr_png_'),
97
+ ('image/jpeg', '_repr_jpeg_'),
98
+ ('application/json', '_repr_json_'),
99
+ ]
100
+
101
+
102
+ def _pm_value(value):
103
+ """A MIME value as JSON holds it: binary data in base64."""
104
+ if isinstance(value, (bytes, bytearray)):
105
+ return base64.b64encode(value).decode('ascii')
106
+ return value
107
+
108
+
109
+ def _pm_bundle(obj):
110
+ """The MIME bundle of an object, as Jupyter builds it from its `_repr_*_` methods; always with text/plain."""
111
+ data = {}
112
+ if not isinstance(obj, type):
113
+ method = getattr(obj, '_repr_mimebundle_', None)
114
+ if callable(method):
115
+ try:
116
+ found = method()
117
+ if isinstance(found, tuple):
118
+ found = found[0]
119
+ if isinstance(found, dict):
120
+ data.update({mime: _pm_value(value) for mime, value in found.items()})
121
+ except Exception:
122
+ pass
123
+ for mime, name in _PM_REPRS:
124
+ method = getattr(obj, name, None)
125
+ if mime in data or not callable(method):
126
+ continue
127
+ try:
128
+ value = method()
129
+ except Exception:
130
+ continue
131
+ if isinstance(value, tuple):
132
+ value = value[0]
133
+ if value is not None:
134
+ data[mime] = _pm_value(value)
135
+ data.setdefault('text/plain', repr(obj))
136
+ return data
137
+
138
+
139
+ def display(*objs, raw=False, **_options):
140
+ """Shows objects as rich outputs (Jupyter's `display`); `raw=True` takes MIME bundles."""
141
+ for obj in objs:
142
+ data = dict(obj) if raw and isinstance(obj, dict) else _pm_bundle(obj)
143
+ if _PM_CURRENT is not None:
144
+ _PM_CURRENT.display({mime: _pm_value(value) for mime, value in data.items()})
145
+ else:
146
+ print(data.get('text/plain', ''))
147
+
148
+
149
+ class _PmDisplayObject:
150
+ """The display objects of IPython.display that the runner provides: data of one MIME type."""
151
+ mime = 'text/plain'
152
+ binary = False
153
+
154
+ def __init__(self, data=None, url=None, filename=None, **_options):
155
+ if filename is not None:
156
+ with open(filename, 'rb' if self.binary else 'r') as f:
157
+ data = f.read()
158
+ self.data = data
159
+ self.url = url
160
+
161
+ def _repr_mimebundle_(self, include=None, exclude=None):
162
+ return {self.mime: _pm_value(self.data), 'text/plain': '<%s object>' % type(self).__name__}
163
+
164
+
165
+ class _PmMarkdown(_PmDisplayObject):
166
+ mime = 'text/markdown'
167
+
168
+
169
+ class _PmHTML(_PmDisplayObject):
170
+ mime = 'text/html'
171
+
172
+
173
+ class _PmLatex(_PmDisplayObject):
174
+ mime = 'text/latex'
175
+
176
+
177
+ class _PmSVG(_PmDisplayObject):
178
+ mime = 'image/svg+xml'
179
+
180
+
181
+ class _PmJSON(_PmDisplayObject):
182
+ mime = 'application/json'
183
+
184
+
185
+ class _PmImage(_PmDisplayObject):
186
+ binary = True
187
+
188
+ def __init__(self, data=None, url=None, filename=None, format=None, **options):
189
+ super().__init__(data, url, filename, **options)
190
+ kind = (format or os.path.splitext(filename or '')[1].lstrip('.') or 'png').lower()
191
+ self.mime = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'svg': 'image/svg+xml'}.get(kind, 'image/png')
192
+ if self.mime == 'image/svg+xml' and isinstance(self.data, (bytes, bytearray)):
193
+ self.data = self.data.decode('utf-8')
194
+
195
+
196
+ def _pm_figures():
197
+ """The open matplotlib figures as PNG outputs, then closed (plt.show(), and the end of a run)."""
198
+ plt = sys.modules.get('matplotlib.pyplot')
199
+ if plt is None or _PM_CURRENT is None:
200
+ return
201
+ for number in plt.get_fignums():
202
+ figure = plt.figure(number)
203
+ data = io.BytesIO()
204
+ figure.savefig(data, format='png', bbox_inches='tight')
205
+ _PM_CURRENT.display({'image/png': base64.b64encode(data.getvalue()).decode('ascii'), 'text/plain': repr(figure)})
206
+ plt.close('all')
207
+
208
+
209
+ class _PmModules:
210
+ """
211
+ The modules the runner provides, made when the code imports them, the same in the browser and
212
+ at compile time (an installed IPython is not used): `IPython` and `IPython.display` with
213
+ `display` and its display objects, for an IPython without a shell; `_presmium_matplotlib`,
214
+ the matplotlib backend of the runs, which draws with Agg and shows the figures as outputs on
215
+ plt.show() and at the end of a run, like Jupyter's inline backend.
216
+ """
217
+
218
+ @classmethod
219
+ def find_spec(cls, name, _path=None, _target=None):
220
+ if name in ('IPython', 'IPython.display', '_presmium_matplotlib'):
221
+ return importlib.util.spec_from_loader(name, cls, is_package=name == 'IPython')
222
+ return None
223
+
224
+ @staticmethod
225
+ def create_module(_spec):
226
+ return None
227
+
228
+ @staticmethod
229
+ def exec_module(module):
230
+ if module.__name__ == '_presmium_matplotlib':
231
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
232
+ module.FigureCanvas = FigureCanvasAgg
233
+ module.show = lambda *_args, **_kwargs: _pm_figures()
234
+ elif module.__name__ == 'IPython':
235
+ module.get_ipython = lambda: None
236
+ # Newer than the versions whose shell matplotlib adjusts.
237
+ module.version_info = (8, 24, 0, '')
238
+ else:
239
+ for name, value in [('display', display), ('Markdown', _PmMarkdown), ('HTML', _PmHTML), ('Latex', _PmLatex),
240
+ ('SVG', _PmSVG), ('JSON', _PmJSON), ('Image', _PmImage)]:
241
+ setattr(module, name, value)
242
+
243
+
244
+ if _PmModules not in sys.meta_path:
245
+ sys.meta_path.insert(0, _PmModules)
246
+
247
+
248
+ class _PmIsolation:
249
+ """
250
+ The working directory of a run and what it changes in the interpreter. Without a session the
251
+ run gets a fresh directory (`dir`, or a new temporary one, removed afterwards) and the state it
252
+ may change is restored; in a session the directory and the state persist.
253
+ """
254
+
255
+ def __init__(self, dir=None, session=None):
256
+ self.given = dir
257
+ self.session = session
258
+
259
+ def __enter__(self):
260
+ self.limit = sys.getrecursionlimit()
261
+ self.path = list(sys.path)
262
+ self.modules = set(sys.modules)
263
+ self.builtins = dict(builtins.__dict__)
264
+ self.stdin = sys.stdin
265
+ self.cwd = os.getcwd()
266
+ self.backend = os.environ.get('MPLBACKEND')
267
+ if self.given is not None:
268
+ self.dir = self.given
269
+ elif self.session is not None:
270
+ self.dir = self.session['dir']
271
+ else:
272
+ self.dir = tempfile.mkdtemp(prefix='presmium-run-')
273
+ os.chdir(self.dir)
274
+ os.environ['MPLBACKEND'] = 'module://_presmium_matplotlib'
275
+ return self
276
+
277
+ def __exit__(self, *_exc):
278
+ sys.setrecursionlimit(self.limit)
279
+ os.chdir(self.cwd)
280
+ sys.stdin = self.stdin
281
+ if self.backend is None:
282
+ os.environ.pop('MPLBACKEND', None)
283
+ else:
284
+ os.environ['MPLBACKEND'] = self.backend
285
+ if self.session is not None:
286
+ return False
287
+ if self.given is None:
288
+ shutil.rmtree(self.dir, ignore_errors=True)
289
+ sys.path[:] = self.path
290
+ for name in set(sys.modules) - self.modules:
291
+ del sys.modules[name]
292
+ for name in set(builtins.__dict__) - set(self.builtins):
293
+ del builtins.__dict__[name]
294
+ builtins.__dict__.update(self.builtins)
295
+ return False
296
+
297
+
298
+ def _pm_arg(raw):
299
+ """
300
+ A parameter as the program gets it (`RunRequest.args` are strings, docs/architecture.md 4.6): a
301
+ Python literal (`10`, `0.5`, `'hi'`, `[1, 2]`, `False`), else JSON (`true`, `false`, `null`, a
302
+ mapping written as JSON), else the string itself. The Jupyter environments read parameters back
303
+ with the same rule (addon-exec-jupyter's `argumentsCode`).
304
+ """
305
+ try:
306
+ return ast.literal_eval(raw)
307
+ except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError):
308
+ pass
309
+ try:
310
+ return json.loads(raw)
311
+ except (ValueError, TypeError, RecursionError):
312
+ return raw
313
+
314
+
315
+ def _pm_prepare(code, args_json, stdin, files_json, scope):
316
+ for path, content in json.loads(files_json).items():
317
+ folder = os.path.dirname(path)
318
+ if folder:
319
+ os.makedirs(folder, exist_ok=True)
320
+ with open(path, 'w') as f:
321
+ f.write(content)
322
+ sys.stdin = io.StringIO(stdin)
323
+ linecache.cache[_PM_FILE] = (len(code), None, code.splitlines(True), _PM_FILE)
324
+ scope.setdefault('__name__', '__main__')
325
+ scope.setdefault('display', display)
326
+ for name, raw in json.loads(args_json).items():
327
+ scope[name] = _pm_arg(raw)
328
+ return compile(code, _PM_FILE, 'exec')
329
+
330
+
331
+ def _pm_split(code, flags=0):
332
+ """
333
+ The code as Jupyter runs it (docs/ir.md 6.1): its statements, and apart the last one when it is
334
+ an expression, whose value is the run's `execute_result` (`_pm_result`); `(program, None)`
335
+ without one, or when a `;` ends the code, which hides the value as in Jupyter.
336
+ """
337
+ tree = ast.parse(code, _PM_FILE)
338
+ if not tree.body or not isinstance(tree.body[-1], ast.Expr) or code.rstrip().endswith(';'):
339
+ return compile(tree, _PM_FILE, 'exec', flags=flags), None
340
+ last = ast.Expression(tree.body.pop().value)
341
+ return compile(tree, _PM_FILE, 'exec', flags=flags), compile(last, _PM_FILE, 'eval', flags=flags)
342
+
343
+
344
+ def _pm_result(outputs, value):
345
+ """The value of the last expression as an `execute_result`, with the MIME bundle `display` shows; None shows nothing."""
346
+ if value is not None:
347
+ outputs.display({mime: _pm_value(item) for mime, item in _pm_bundle(value).items()}, 'execute_result')
348
+
349
+
350
+ def _pm_user_traceback():
351
+ """The current exception, its traceback starting at the user's code."""
352
+ etype, value, tb = sys.exc_info()
353
+ while tb is not None and tb.tb_frame.f_code.co_filename != _PM_FILE:
354
+ tb = tb.tb_next
355
+ return etype, value, tb
356
+
357
+
358
+ def _pm_session(name, dir=None):
359
+ """A session's state; its working directory is `dir` when given (the run directory), else one of its own."""
360
+ if name not in _PM_SESSIONS:
361
+ own = dir is None
362
+ _PM_SESSIONS[name] = {'scope': {}, 'dir': tempfile.mkdtemp(prefix='presmium-session-') if own else dir, 'own': own}
363
+ return _PM_SESSIONS[name]
364
+
365
+
366
+ def _pm_end(name):
367
+ """Drops a session: its globals and the working directory it made."""
368
+ session = _PM_SESSIONS.pop(name, None)
369
+ if session is not None and session['own']:
370
+ shutil.rmtree(session['dir'], ignore_errors=True)
371
+
372
+
373
+ def _pm_run(code, args_json, stdin, files_json, _max_events, session_name=None, dir=None):
374
+ global _PM_CURRENT
375
+ outputs = _PmOutputs()
376
+ exit_code = 0
377
+ session = _pm_session(session_name, dir) if session_name else None
378
+ scope = session['scope'] if session is not None else {}
379
+ _PM_CURRENT = outputs
380
+ try:
381
+ with _PmIsolation(dir, session), contextlib.redirect_stdout(_PmStream(outputs, 'stdout')), contextlib.redirect_stderr(_PmStream(outputs, 'stderr')):
382
+ try:
383
+ _pm_prepare(code, args_json, stdin, files_json, scope)
384
+ program, last = _pm_split(code)
385
+ exec(program, scope)
386
+ if last is not None:
387
+ _pm_result(outputs, eval(last, scope))
388
+ _pm_figures()
389
+ except SystemExit as e:
390
+ exit_code = e.code if isinstance(e.code, int) else (0 if e.code is None else 1)
391
+ except BaseException:
392
+ outputs.error(*_pm_user_traceback())
393
+ exit_code = 1
394
+ finally:
395
+ _PM_CURRENT = None
396
+ return json.dumps({'stdout': ''.join(outputs.text['stdout']), 'stderr': ''.join(outputs.text['stderr']), 'exitCode': exit_code, 'outputs': outputs.items})
397
+
398
+
399
+ def _pm_trace(code, args_json, stdin, files_json, max_events, _session_name=None):
400
+ """Runs the code once, recording each executed line of it with the output accumulated after the line ran."""
401
+ out = io.StringIO()
402
+ events = []
403
+ truncated = False
404
+
405
+ def tracer(frame, event, _arg):
406
+ nonlocal truncated
407
+ if frame.f_code.co_filename != _PM_FILE:
408
+ return None
409
+ if event == 'line':
410
+ if events:
411
+ events[-1]['output'] = out.getvalue()
412
+ if len(events) >= max_events:
413
+ truncated = True
414
+ raise _PmStop()
415
+ events.append({'line': frame.f_lineno, 'output': ''})
416
+ return tracer
417
+
418
+ with _PmIsolation(), contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
419
+ try:
420
+ scope = {}
421
+ program = _pm_prepare(code, args_json, stdin, files_json, scope)
422
+ sys.settrace(tracer)
423
+ exec(program, scope)
424
+ except (_PmStop, SystemExit):
425
+ pass
426
+ except BaseException:
427
+ out.write(''.join(traceback.format_exception(*_pm_user_traceback())))
428
+ finally:
429
+ sys.settrace(None)
430
+ if events:
431
+ events[-1]['output'] = out.getvalue()
432
+ return json.dumps({'events': events, 'truncated': truncated, 'output': out.getvalue()})
433
+
434
+
435
+ def _pm_main():
436
+ """
437
+ A compile-time process: runs the requests of `.presmium/request.json` in order in the run
438
+ directory, its working directory (one session when the request names one), and writes the
439
+ result of each as soon as it is known, so a run stopped at its timeout keeps the earlier ones.
440
+ """
441
+ here = os.getcwd()
442
+ channel = os.path.join(here, '.presmium')
443
+ with open(os.path.join(channel, 'request.json'), encoding='utf-8') as f:
444
+ request = json.load(f)
445
+ # E.g. the workspace in a container, whose modules stay importable.
446
+ for path in reversed(request.get('path') or []):
447
+ sys.path.insert(1, path)
448
+ session = request.get('session')
449
+ for i, run in enumerate(request['runs']):
450
+ result = _pm_run(run['code'], json.dumps(run.get('args') or {}), run.get('stdin') or '', json.dumps(run.get('files') or {}), 0, session, here)
451
+ partial = os.path.join(channel, 'result-%d.part' % i)
452
+ with open(partial, 'w', encoding='utf-8') as f:
453
+ f.write(result)
454
+ os.replace(partial, os.path.join(channel, 'result-%d.json' % i))
@@ -0,0 +1,146 @@
1
+ import satisfies from 'semver/functions/satisfies.js'
2
+ import validRange from 'semver/ranges/valid.js'
3
+ import { KERNEL_VERSION } from './contracts.ts'
4
+ import type { PluginManifest, ServiceToken } from './contracts.ts'
5
+
6
+ export const HOST_OWNER = 'host'
7
+
8
+ export interface StartPlan {
9
+ order: string[]
10
+ excluded: Map<string, string>
11
+ cycles: string[][]
12
+ }
13
+
14
+ interface Entry {
15
+ impl: unknown
16
+ owner: string
17
+ }
18
+
19
+ export class ServiceRegistry {
20
+ #entries = new Map<string, Entry>()
21
+
22
+ provide<T>(token: ServiceToken<T>, impl: T, owner: string = HOST_OWNER): void {
23
+ const existing = this.#entries.get(token.id)
24
+ if (existing) throw new Error(`service ${token.id} is already provided by ${existing.owner}`)
25
+ this.#entries.set(token.id, { impl, owner })
26
+ }
27
+
28
+ has(token: ServiceToken<unknown>): boolean {
29
+ return this.#entries.has(token.id)
30
+ }
31
+
32
+ get<T>(token: ServiceToken<T>): T {
33
+ const entry = this.#entries.get(token.id)
34
+ if (!entry) throw new Error(`service ${token.id} is not available`)
35
+ return entry.impl as T
36
+ }
37
+
38
+ tryGet<T>(token: ServiceToken<T>): T | undefined {
39
+ return this.#entries.get(token.id)?.impl as T | undefined
40
+ }
41
+
42
+ owner(token: ServiceToken<unknown>): string | undefined {
43
+ return this.#entries.get(token.id)?.owner
44
+ }
45
+
46
+ revoke(owner: string): void {
47
+ for (const [id, entry] of this.#entries) {
48
+ if (entry.owner === owner) this.#entries.delete(id)
49
+ }
50
+ }
51
+
52
+ ids(): string[] {
53
+ return [...this.#entries.keys()]
54
+ }
55
+
56
+ plan(manifests: PluginManifest[], kernelVersion: string = KERNEL_VERSION): StartPlan {
57
+ const excluded = new Map<string, string>()
58
+ for (const m of manifests) {
59
+ const reason = incompatibility(m.kernel, kernelVersion)
60
+ if (reason) excluded.set(m.id, reason)
61
+ }
62
+
63
+ const providers = new Map<string, string>([...this.#entries].map(([id, entry]) => [id, entry.owner]))
64
+ for (const m of manifests) {
65
+ if (excluded.has(m.id)) continue
66
+ const taken = m.provides?.find(t => providers.has(t.id))
67
+ if (taken) {
68
+ excluded.set(m.id, `service ${taken.id} is already provided by ${providers.get(taken.id)}`)
69
+ continue
70
+ }
71
+ for (const t of m.provides ?? []) providers.set(t.id, m.id)
72
+ }
73
+
74
+ const ids = manifests.map(m => m.id).filter(id => !excluded.has(id))
75
+ const plugins = new Set(ids)
76
+ const deps = new Map<string, string[]>()
77
+ for (const m of manifests) {
78
+ if (!plugins.has(m.id)) continue
79
+ const on = (m.requires ?? [])
80
+ .map(r => providers.get(r.token.id))
81
+ .filter((p): p is string => p !== undefined && plugins.has(p))
82
+ deps.set(m.id, [...new Set(on)])
83
+ }
84
+
85
+ const cycles = findCycles(ids, deps)
86
+ for (const cycle of cycles) {
87
+ const path = [...cycle, cycle[0]].join(' -> ')
88
+ for (const id of cycle) excluded.set(id, `dependency cycle: ${path}`)
89
+ }
90
+
91
+ return { order: topologicalOrder(ids.filter(id => !excluded.has(id)), deps), excluded, cycles }
92
+ }
93
+ }
94
+
95
+ export function kernelIncompatibility(range: string, version: string = KERNEL_VERSION): string | undefined {
96
+ return incompatibility(range, version)
97
+ }
98
+
99
+ function incompatibility(range: string, version: string): string | undefined {
100
+ if (validRange(range) === null) return `invalid kernel range "${range}"`
101
+ if (!satisfies(version, range, { includePrerelease: true })) return `kernel ${version} does not satisfy "${range}"`
102
+ return undefined
103
+ }
104
+
105
+ function reachable(start: string, deps: Map<string, string[]>): Set<string> {
106
+ const seen = new Set<string>()
107
+ const stack = [...(deps.get(start) ?? [])]
108
+ while (stack.length > 0) {
109
+ const id = stack.pop()!
110
+ if (seen.has(id)) continue
111
+ seen.add(id)
112
+ stack.push(...(deps.get(id) ?? []))
113
+ }
114
+ return seen
115
+ }
116
+
117
+ function findCycles(ids: string[], deps: Map<string, string[]>): string[][] {
118
+ const reach = new Map(ids.map(id => [id, reachable(id, deps)]))
119
+ const onCycle = ids.filter(id => reach.get(id)!.has(id))
120
+ const cycles: string[][] = []
121
+ const grouped = new Set<string>()
122
+ for (const id of onCycle) {
123
+ if (grouped.has(id)) continue
124
+ const members = new Set(onCycle.filter(o => reach.get(id)!.has(o) && reach.get(o)!.has(id)))
125
+ const path = [id]
126
+ for (;;) {
127
+ const next = deps.get(path.at(-1)!)!.find(d => members.has(d) && !path.includes(d))
128
+ if (next === undefined) break
129
+ path.push(next)
130
+ }
131
+ for (const m of members) if (!path.includes(m)) path.push(m)
132
+ path.forEach(m => grouped.add(m))
133
+ cycles.push(path)
134
+ }
135
+ return cycles
136
+ }
137
+
138
+ function topologicalOrder(ids: string[], deps: Map<string, string[]>): string[] {
139
+ const pending = [...ids]
140
+ const order: string[] = []
141
+ while (pending.length > 0) {
142
+ const index = pending.findIndex(id => deps.get(id)!.every(d => !pending.includes(d)))
143
+ order.push(...pending.splice(index, 1))
144
+ }
145
+ return order
146
+ }