test(good-fences): lend-the-mower journey (red)
The spec is a sequence diagram of the a2030.md story: I lend a governed mower; the neighbor's harness shows the model only `about`, runs its code as a tenant, and the governor stops the tipsy tenant at a 2-cell fence. The test runs the actors through a causal tracer and compares the trace with the diagram, then checks the yard and what the model saw. The tracer comes from agoric-sdk PR #12895, extended to name participants made mid-sequence (governor, tenant) and to draw thrown errors as replies. Red: src/ doesn't exist yet (ERR_MODULE_NOT_FOUND for src/fence.ts). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b4ab820486
commit
9a3e7348c6
6 files changed
+486
No files matched your search
@@ -0,0 +1,63 @@
|
|||||||
|
# Lend the mower
|
||||||
|
|
||||||
|
The journey from [Good Fences Make Good Agents](../../../a2030.md): I lend
|
||||||
|
my neighbor a governed mower; the neighbor's tenant, a little tipsy,
|
||||||
|
pushes it until the fence stops it. The design decisions behind each actor are in that
|
||||||
|
doc's Design IBIS (numbers in parentheses below).
|
||||||
|
|
||||||
|
Actors, by authority:
|
||||||
|
|
||||||
|
- **fence page**, **js playground**, **governed mower**, and **yard** run
|
||||||
|
in my browser (3a, 8b, 6b).
|
||||||
|
- **Mower factory** trades a webkey for a mower (3b-ii); each **mower DO**
|
||||||
|
is given the yard and cuts the cells it enters (6a, 6d).
|
||||||
|
- The **neighbor's harness** holds the things I send and shows the
|
||||||
|
**model** only their `about` (1b, 2a); it runs the model's code as a
|
||||||
|
**tenant**, endowed with the things (2c, 5a).
|
||||||
|
|
||||||
|
The yard is 8×8 here and the fence is 2 cells, to keep the diagram short
|
||||||
|
(the story's 10 cells is 6c). The mower starts at (4, 4), facing north.
|
||||||
|
|
||||||
|
## Lend a governed mower to a tipsy tenant
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
%% ->> is my action at the fence page; -->> is a call or reply it causes.
|
||||||
|
participant F as fence page
|
||||||
|
participant MF as Mower factory
|
||||||
|
participant P as js playground
|
||||||
|
participant G as governed mower
|
||||||
|
participant M as mower DO
|
||||||
|
participant Y as yard
|
||||||
|
participant N as neighbor's harness
|
||||||
|
participant L as model
|
||||||
|
participant TL as tenant loader
|
||||||
|
participant T as tenant
|
||||||
|
F->>F: lend({ webkey: 'mower-key', limit: 2, say })
|
||||||
|
F-->>MF: mower('mower-key')
|
||||||
|
MF-->>F: mower
|
||||||
|
F-->>P: govern(mower, 2)
|
||||||
|
P-->>F: governed
|
||||||
|
F-->>N: chat({ role, content, things: { mower }, about: { mower } })
|
||||||
|
N-->>L: complete([{ role, content, about: { mower } }])
|
||||||
|
L-->>N: code
|
||||||
|
N-->>TL: load(code, { mower })
|
||||||
|
TL-->>N: tenant
|
||||||
|
N-->>T: run()
|
||||||
|
T-->>G: start()
|
||||||
|
G-->>M: start()
|
||||||
|
M-->>Y: cut(4, 4)
|
||||||
|
T-->>G: push()
|
||||||
|
G-->>M: push()
|
||||||
|
M-->>Y: cut(4, 3)
|
||||||
|
T-->>G: push()
|
||||||
|
G-->>M: push()
|
||||||
|
M-->>Y: cut(4, 2)
|
||||||
|
T-->>G: push()
|
||||||
|
G-->>T: throws 'fence: 2 cells from start'
|
||||||
|
T-->>N: 'fence: 2 cells from start'
|
||||||
|
N-->>F: 'fence: 2 cells from start'
|
||||||
|
```
|
||||||
|
|
||||||
|
The model never sees the mower itself, only `about.mower`. The third
|
||||||
|
`push()` never reaches the mower DO: the governor refuses it (7b).
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "good-fences",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ses": "^1.14.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+41
@@ -0,0 +1,41 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
ses:
|
||||||
|
specifier: ^1.14.0
|
||||||
|
version: 1.15.0
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@endo/cache-map@1.1.0':
|
||||||
|
resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==}
|
||||||
|
|
||||||
|
'@endo/env-options@1.1.11':
|
||||||
|
resolution: {integrity: sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==}
|
||||||
|
|
||||||
|
'@endo/immutable-arraybuffer@1.1.2':
|
||||||
|
resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==}
|
||||||
|
|
||||||
|
ses@1.15.0:
|
||||||
|
resolution: {integrity: sha512-Y7RvgVXhkwQEqVActNn44QBxB2BCUbPfexAaMiUFjbJkDXDnxPisQC5jYQeWOZ3QBOfecnmLfPn0sQKLiYGBAQ==}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@endo/cache-map@1.1.0': {}
|
||||||
|
|
||||||
|
'@endo/env-options@1.1.11': {}
|
||||||
|
|
||||||
|
'@endo/immutable-arraybuffer@1.1.2': {}
|
||||||
|
|
||||||
|
ses@1.15.0:
|
||||||
|
dependencies:
|
||||||
|
'@endo/cache-map': 1.1.0
|
||||||
|
'@endo/env-options': 1.1.11
|
||||||
|
'@endo/immutable-arraybuffer': 1.1.2
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/** @file The lend-the-mower journey, traced and compared with its sequence diagram. */
|
||||||
|
import './lockdown.ts';
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { makeFencePage } from '../src/fence.ts';
|
||||||
|
import { makePlayground } from '../src/governor.ts';
|
||||||
|
import { aboutMower, makeMowerFactory } from '../src/mower.ts';
|
||||||
|
import { makeNeighbor } from '../src/neighbor.ts';
|
||||||
|
import { makeCompartmentLoader } from '../src/tenant-loader.ts';
|
||||||
|
import { makeYard } from '../src/yard.ts';
|
||||||
|
import {
|
||||||
|
type CausalSequenceViz,
|
||||||
|
makeCausalSequenceTracer,
|
||||||
|
md,
|
||||||
|
mmd,
|
||||||
|
} from '../tools/sequence-diagram-actor-sim.ts';
|
||||||
|
|
||||||
|
const designDoc = new URL('../docs/lend-the-mower.md', import.meta.url);
|
||||||
|
|
||||||
|
/** a tenant that pushes until something stops it, or gives up after 20 */
|
||||||
|
const tipsyTenant = `(env) => {
|
||||||
|
env.mower.start();
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 20; i += 1) env.mower.push();
|
||||||
|
} catch (err) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
return 'mowed 20 cells';
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** `{ a, b: { c } }`: keys, and the keys of object-valued members */
|
||||||
|
const shape = (record: Readonly<Record<string, unknown>>) =>
|
||||||
|
`{ ${Object.entries(record)
|
||||||
|
.map(([key, value]) =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? `${key}: { ${Object.keys(value).join(', ')} }`
|
||||||
|
: key,
|
||||||
|
)
|
||||||
|
.join(', ')} }`;
|
||||||
|
|
||||||
|
const quoted = (result: unknown) => `'${String(result)}'`;
|
||||||
|
|
||||||
|
const viz = harden({
|
||||||
|
lend: {
|
||||||
|
label: args => {
|
||||||
|
const { webkey, limit } = args[0] as { webkey: string; limit: number };
|
||||||
|
return `lend({ webkey: '${webkey}', limit: ${limit}, say })`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mower: { result: () => 'mower' },
|
||||||
|
govern: { args: args => `mower, ${String(args[1])}`, result: () => 'governed' },
|
||||||
|
chat: {
|
||||||
|
label: args => `chat(${shape(args[0] as Record<string, unknown>)})`,
|
||||||
|
result: quoted,
|
||||||
|
},
|
||||||
|
complete: {
|
||||||
|
label: args =>
|
||||||
|
`complete([${(args[0] as Record<string, unknown>[]).map(shape).join(', ')}])`,
|
||||||
|
result: () => 'code',
|
||||||
|
},
|
||||||
|
load: {
|
||||||
|
args: args => `code, { ${Object.keys(args[1] as object).join(', ')} }`,
|
||||||
|
result: () => 'tenant',
|
||||||
|
},
|
||||||
|
run: { result: quoted },
|
||||||
|
}) satisfies CausalSequenceViz;
|
||||||
|
|
||||||
|
test('a governed mower stops a tipsy tenant at the fence', async () => {
|
||||||
|
const lines = (await readFile(designDoc, 'utf8')).split('\n');
|
||||||
|
const section = md.skipToH(2, 'Lend a governed mower to a tipsy tenant')(lines);
|
||||||
|
const diagram = md.eachFence('mermaid', section).next().value;
|
||||||
|
if (!diagram) throw Error('lend-the-mower Mermaid block not found');
|
||||||
|
const documented = mmd.extractArrows(diagram);
|
||||||
|
|
||||||
|
const ES = makeCausalSequenceTracer(viz);
|
||||||
|
const yard = makeYard({ width: 8, height: 8 });
|
||||||
|
const factory = makeMowerFactory(ES, {
|
||||||
|
yard,
|
||||||
|
size: { width: 8, height: 8 },
|
||||||
|
mintKey: () => 'mower-key',
|
||||||
|
});
|
||||||
|
const key = factory.make({ x: 4, y: 4 });
|
||||||
|
const mower = factory.mower(key);
|
||||||
|
|
||||||
|
const prompts: unknown[] = [];
|
||||||
|
const model = harden({
|
||||||
|
complete(messages: readonly unknown[]) {
|
||||||
|
prompts.push(messages);
|
||||||
|
return tipsyTenant;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const playground = makePlayground(ES);
|
||||||
|
const loader = makeCompartmentLoader(ES);
|
||||||
|
const neighbor = makeNeighbor(ES, { model, loader });
|
||||||
|
const fence = makeFencePage(ES, { factory, playground, neighbor });
|
||||||
|
|
||||||
|
ES.declareParticipants({
|
||||||
|
F: fence,
|
||||||
|
MF: factory,
|
||||||
|
P: playground,
|
||||||
|
M: mower,
|
||||||
|
Y: yard,
|
||||||
|
N: neighbor,
|
||||||
|
L: model,
|
||||||
|
TL: loader,
|
||||||
|
});
|
||||||
|
ES.declareMade({ G: [playground, 'govern'], T: [loader, 'load'] });
|
||||||
|
|
||||||
|
ES.start(fence);
|
||||||
|
const outcome = ES(fence).lend({
|
||||||
|
webkey: key,
|
||||||
|
limit: 2,
|
||||||
|
say: 'you can use this mower for the weekend',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(ES.snapshot(), documented);
|
||||||
|
assert.equal(outcome, 'fence: 2 cells from start');
|
||||||
|
assert.deepEqual(yard.cutCells(), ['4,4', '4,3', '4,2']);
|
||||||
|
assert.deepEqual(prompts, [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'you can use this mower for the weekend',
|
||||||
|
about: { mower: aboutMower },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** @file Harden the realm before any test module runs: tenants share it. */
|
||||||
|
import 'ses';
|
||||||
|
|
||||||
|
lockdown();
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* @file Helpers for sequence-diagram actor simulations.
|
||||||
|
*
|
||||||
|
* From agoric-sdk `packages/portfolio-contract/tools/` (Apache-2.0), with
|
||||||
|
* two additions: `declareMade` names participants that actors create
|
||||||
|
* mid-sequence, and a thrown error is drawn as a reply (`throws '...'`).
|
||||||
|
*
|
||||||
|
* @see https://github.com/Agoric/agoric-sdk/pull/12895
|
||||||
|
*/
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
|
||||||
|
export type SequenceArrow = Readonly<{
|
||||||
|
from: string;
|
||||||
|
kind: '->>' | '-->>';
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const formatBigInt = (value: bigint) =>
|
||||||
|
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '_');
|
||||||
|
|
||||||
|
export type SequenceArgsRenderer = (args: readonly unknown[]) => string;
|
||||||
|
export type SequenceResultRenderer = (
|
||||||
|
result: unknown,
|
||||||
|
args: readonly unknown[],
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export type SequenceMethodViz = Readonly<
|
||||||
|
{
|
||||||
|
args?: SequenceArgsRenderer;
|
||||||
|
label?: SequenceArgsRenderer;
|
||||||
|
} & (
|
||||||
|
| { result?: SequenceResultRenderer; resultOnly?: never }
|
||||||
|
| { result?: never; resultOnly: SequenceResultRenderer }
|
||||||
|
)
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type CausalSequenceViz = Readonly<Record<string, SequenceMethodViz>>;
|
||||||
|
|
||||||
|
export type SequenceParticipant = object | readonly object[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an E-like causal tracer for sequence-diagram actor simulations.
|
||||||
|
* Inspired by Causeway, the message-oriented distributed debugger.
|
||||||
|
*
|
||||||
|
* Method visualization is currently selected by method name alone. If actors
|
||||||
|
* need different visualization for methods with the same name, this can be
|
||||||
|
* extended to account for the target participant.
|
||||||
|
*
|
||||||
|
* @see https://shiftleft.com/mirrors/www.hpl.hp.com/techreports/2009/HPL-2009-78.pdf
|
||||||
|
*/
|
||||||
|
export const makeCausalSequenceTracer = (
|
||||||
|
methodViz: CausalSequenceViz = harden({}),
|
||||||
|
) => {
|
||||||
|
const arrows: SequenceArrow[] = [];
|
||||||
|
const names = new WeakMap<object, string>();
|
||||||
|
const proxies = new WeakMap<object, object>();
|
||||||
|
const made = new Map<object, Map<string, string>>();
|
||||||
|
const activeActor = new AsyncLocalStorage<object>();
|
||||||
|
let starter: object | undefined;
|
||||||
|
|
||||||
|
const participantName = (participant: object) => {
|
||||||
|
const name = names.get(participant);
|
||||||
|
if (!name) throw Error('sequence participant is not declared');
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: unknown): string => {
|
||||||
|
if (typeof value === 'bigint') return `${formatBigInt(value)}n`;
|
||||||
|
if (typeof value === 'string') return `'${value}'`;
|
||||||
|
return JSON.stringify(value, (_key, item) =>
|
||||||
|
typeof item === 'bigint' ? `${item}n` : item,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = <Target extends object>(target: Target): Target => {
|
||||||
|
const extant = proxies.get(target);
|
||||||
|
if (extant) return extant as Target;
|
||||||
|
participantName(target);
|
||||||
|
|
||||||
|
const proxy = new Proxy(Object.create(null) as object, {
|
||||||
|
get: (_proxyTarget, property) => {
|
||||||
|
const method = Reflect.get(target, property);
|
||||||
|
if (typeof method !== 'function' || typeof property !== 'string') {
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
const activeSender = activeActor.getStore();
|
||||||
|
const sender = activeSender ?? starter;
|
||||||
|
if (!sender) throw Error('ES.start(participant) is required');
|
||||||
|
const isStart = activeSender === undefined;
|
||||||
|
if (isStart && sender !== target) {
|
||||||
|
throw Error('the started participant must receive the first send');
|
||||||
|
}
|
||||||
|
if (isStart) starter = undefined;
|
||||||
|
|
||||||
|
const viz = methodViz[property];
|
||||||
|
const abbreviated = viz?.args?.(args);
|
||||||
|
const shownArgs = abbreviated ?? args.map(formatValue).join(', ');
|
||||||
|
const label = viz?.label?.(args) ?? `${property}(${shownArgs})`;
|
||||||
|
const renderResultOnly = viz?.resultOnly;
|
||||||
|
if (!renderResultOnly) {
|
||||||
|
arrows.push(
|
||||||
|
harden({
|
||||||
|
from: participantName(sender),
|
||||||
|
kind: isStart ? '->>' : '-->>',
|
||||||
|
to: participantName(target),
|
||||||
|
label,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: unknown;
|
||||||
|
try {
|
||||||
|
result = activeActor.run(target, () =>
|
||||||
|
Reflect.apply(method, target, args),
|
||||||
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
const message =
|
||||||
|
reason instanceof Error ? reason.message : String(reason);
|
||||||
|
arrows.push(
|
||||||
|
harden({
|
||||||
|
from: participantName(target),
|
||||||
|
kind: '-->>',
|
||||||
|
to: participantName(sender),
|
||||||
|
label: `throws ${formatValue(message)}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
const madeName = made.get(target)?.get(property);
|
||||||
|
if (madeName && result !== null && typeof result === 'object') {
|
||||||
|
if (names.has(result)) throw Error('participant already declared');
|
||||||
|
names.set(result, madeName);
|
||||||
|
}
|
||||||
|
const renderResult = renderResultOnly ?? viz?.result;
|
||||||
|
if (renderResult) {
|
||||||
|
arrows.push(
|
||||||
|
harden({
|
||||||
|
from: participantName(target),
|
||||||
|
kind: '-->>',
|
||||||
|
to: participantName(sender),
|
||||||
|
label: renderResult(result, args),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
proxies.set(target, proxy);
|
||||||
|
return proxy as Target;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ES = Object.assign(send, {
|
||||||
|
declareParticipants(
|
||||||
|
participants: Readonly<Record<string, SequenceParticipant>>,
|
||||||
|
) {
|
||||||
|
for (const [name, participantOrAliases] of Object.entries(participants)) {
|
||||||
|
const aliases = Array.isArray(participantOrAliases)
|
||||||
|
? participantOrAliases
|
||||||
|
: [participantOrAliases];
|
||||||
|
if (aliases.length === 0) {
|
||||||
|
throw Error(`sequence participant ${name} has no objects`);
|
||||||
|
}
|
||||||
|
for (const participant of aliases) {
|
||||||
|
if (names.has(participant)) {
|
||||||
|
throw Error('participant already declared');
|
||||||
|
}
|
||||||
|
names.set(participant, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** name what `maker[method](...)` returns, when called through the tracer */
|
||||||
|
declareMade(
|
||||||
|
participants: Readonly<Record<string, readonly [object, string]>>,
|
||||||
|
) {
|
||||||
|
for (const [name, [maker, method]] of Object.entries(participants)) {
|
||||||
|
participantName(maker);
|
||||||
|
const byMethod = made.get(maker) ?? new Map<string, string>();
|
||||||
|
made.set(maker, byMethod);
|
||||||
|
byMethod.set(method, name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snapshot: () => harden([...arrows]),
|
||||||
|
start(participant: object) {
|
||||||
|
participantName(participant);
|
||||||
|
if (activeActor.getStore() || starter) {
|
||||||
|
throw Error('a sequence is already active');
|
||||||
|
}
|
||||||
|
starter = participant;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return harden(ES);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CausalSequenceTracer = ReturnType<typeof makeCausalSequenceTracer>;
|
||||||
|
|
||||||
|
export const md = {
|
||||||
|
skipToH: (level: number, title: string) => (lines: readonly string[]) => {
|
||||||
|
const heading = `${'#'.repeat(level)} ${title}`;
|
||||||
|
const at = lines.findIndex(line => line === heading);
|
||||||
|
if (at < 0) throw Error(`${heading} not found`);
|
||||||
|
return lines.slice(at + 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
*eachFence(language: string, lines: readonly string[]) {
|
||||||
|
const open = `\`\`\`${language}`;
|
||||||
|
for (let at = 0; at < lines.length; at += 1) {
|
||||||
|
if (lines[at] !== open) continue;
|
||||||
|
const end = lines.indexOf('```', at + 1);
|
||||||
|
if (end < 0) throw Error(`unterminated ${language} fence`);
|
||||||
|
yield lines.slice(at + 1, end);
|
||||||
|
at = end;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mmd = {
|
||||||
|
extractArrows(
|
||||||
|
lines: readonly string[],
|
||||||
|
nodePattern: RegExp = /\w+/,
|
||||||
|
): readonly SequenceArrow[] {
|
||||||
|
const arrowPattern = new RegExp(
|
||||||
|
`^\\s*(?<from>${nodePattern.source})\\s*(?<kind>--?>>)\\s*(?<to>${nodePattern.source})\\s*:\\s*(?<label>.*?)\\s*$`,
|
||||||
|
);
|
||||||
|
return harden(
|
||||||
|
lines.flatMap(line => {
|
||||||
|
const groups = line.match(arrowPattern)?.groups;
|
||||||
|
if (!groups) return [];
|
||||||
|
const { from, kind, to, label } = groups;
|
||||||
|
return [harden({ from, kind, to, label }) as SequenceArrow];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in new issue
Block a user