@intentius/chant 0.124.17 → 0.124.19
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/dist/__golden__/wave-loop/fan-out-applied.json +511 -0
- package/dist/__golden__/wave-loop/fan-out-apply-fails.json +406 -0
- package/dist/__golden__/wave-loop/fan-out-gated.json +707 -0
- package/dist/__golden__/wave-loop/fan-out-no-members.json +898 -0
- package/dist/__golden__/wave-loop/fan-out-only.json +514 -0
- package/dist/__golden__/wave-loop/fan-out-plan-fails.json +359 -0
- package/dist/__golden__/wave-loop/fan-out-same-root.json +311 -0
- package/dist/__golden__/wave-loop/op-by-apply-after-fails.json +401 -0
- package/dist/__golden__/wave-loop/op-by-apply-hold.json +756 -0
- package/dist/__golden__/wave-loop/op-by-apply-ignored.json +340 -0
- package/dist/__golden__/wave-loop/op-by-apply-pass.json +400 -0
- package/dist/__golden__/wave-loop/op-by-apply-skip.json +404 -0
- package/dist/__golden__/wave-loop/op-cost-member-clash.json +62 -0
- package/dist/__golden__/wave-loop/op-cost-member.json +191 -0
- package/dist/__golden__/wave-loop/op-denied-source.json +407 -0
- package/dist/__golden__/wave-loop/op-denied.json +399 -0
- package/dist/__golden__/wave-loop/op-discover-rest-short.json +92 -0
- package/dist/__golden__/wave-loop/op-discover-rest-stops.json +340 -0
- package/dist/__golden__/wave-loop/op-discover-rest.json +659 -0
- package/dist/__golden__/wave-loop/op-discover-wide.json +290 -0
- package/dist/__golden__/wave-loop/op-held-on-again.json +176 -0
- package/dist/__golden__/wave-loop/op-held-on-before-apply.json +689 -0
- package/dist/__golden__/wave-loop/op-held-on.json +1455 -0
- package/dist/__golden__/wave-loop/op-hooks-apply-fails.json +673 -0
- package/dist/__golden__/wave-loop/op-hooks-fail-after-apply.json +737 -0
- package/dist/__golden__/wave-loop/op-hooks-fail-before-apply.json +450 -0
- package/dist/__golden__/wave-loop/op-hooks-fail-before-plan.json +97 -0
- package/dist/__golden__/wave-loop/op-hooks-held.json +1059 -0
- package/dist/__golden__/wave-loop/op-hooks-hold-before-apply.json +693 -0
- package/dist/__golden__/wave-loop/op-hooks-pass.json +735 -0
- package/dist/__golden__/wave-loop/op-hooks-refused-at-base.json +148 -0
- package/dist/__golden__/wave-loop/op-hooks-share.json +1225 -0
- package/dist/__golden__/wave-loop/op-outcome-held.json +202 -0
- package/dist/__golden__/wave-loop/op-outcome-replan-approved.json +286 -0
- package/dist/__golden__/wave-loop/op-outcome-replan-forever.json +823 -0
- package/dist/__golden__/wave-loop/op-outcome-replan.json +259 -0
- package/dist/__golden__/wave-loop/op-outcome-stood-down.json +196 -0
- package/dist/__golden__/wave-loop/op-outcome-unreadable.json +193 -0
- package/dist/__golden__/wave-loop/op-overridden.json +508 -0
- package/dist/__golden__/wave-loop/op-per-run.json +518 -0
- package/dist/__golden__/wave-loop/op-point.json +495 -0
- package/dist/__golden__/wave-loop/op-pr-review.json +397 -0
- package/dist/__golden__/wave-loop/op-sealed.json +125 -0
- package/dist/__golden__/wave-loop/op-spend-change-only.json +168 -0
- package/dist/__golden__/wave-loop/op-spend.json +328 -0
- package/dist/__golden__/wave-loop/op-wave-commands.json +1808 -0
- package/dist/__golden__/wave-loop/op-wide-refused.json +291 -0
- package/dist/__golden__/wave-loop/op-wide-waiting.json +231 -0
- package/dist/__golden__/wave-loop/op-wide.json +440 -0
- package/dist/cli/commands/import.d.ts +2 -0
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/commands/import.js +6 -2
- package/dist/cli/handlers/graph.js +24 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/misc.js +2 -1
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +4 -0
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/fan-out-run.d.ts +4 -3
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +138 -123
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +9 -3
- package/dist/graph-dot.d.ts.map +1 -1
- package/dist/graph-dot.js +36 -2
- package/dist/graph-ir.d.ts +98 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +71 -0
- package/dist/graph-lens.js +12 -1
- package/dist/graph-mermaid.d.ts +5 -1
- package/dist/graph-mermaid.d.ts.map +1 -1
- package/dist/graph-mermaid.js +46 -2
- package/dist/lexicon.d.ts +22 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +7 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +129 -72
- package/dist/wave-engine.d.ts +74 -5
- package/dist/wave-engine.d.ts.map +1 -1
- package/dist/wave-engine.js +47 -5
- package/dist/wave-runtime.mjs +215 -215
- package/package.json +1 -1
- package/src/__golden__/wave-loop/fan-out-applied.json +511 -0
- package/src/__golden__/wave-loop/fan-out-apply-fails.json +406 -0
- package/src/__golden__/wave-loop/fan-out-gated.json +707 -0
- package/src/__golden__/wave-loop/fan-out-no-members.json +898 -0
- package/src/__golden__/wave-loop/fan-out-only.json +514 -0
- package/src/__golden__/wave-loop/fan-out-plan-fails.json +359 -0
- package/src/__golden__/wave-loop/fan-out-same-root.json +311 -0
- package/src/__golden__/wave-loop/op-by-apply-after-fails.json +401 -0
- package/src/__golden__/wave-loop/op-by-apply-hold.json +756 -0
- package/src/__golden__/wave-loop/op-by-apply-ignored.json +340 -0
- package/src/__golden__/wave-loop/op-by-apply-pass.json +400 -0
- package/src/__golden__/wave-loop/op-by-apply-skip.json +404 -0
- package/src/__golden__/wave-loop/op-cost-member-clash.json +62 -0
- package/src/__golden__/wave-loop/op-cost-member.json +191 -0
- package/src/__golden__/wave-loop/op-denied-source.json +407 -0
- package/src/__golden__/wave-loop/op-denied.json +399 -0
- package/src/__golden__/wave-loop/op-discover-rest-short.json +92 -0
- package/src/__golden__/wave-loop/op-discover-rest-stops.json +340 -0
- package/src/__golden__/wave-loop/op-discover-rest.json +659 -0
- package/src/__golden__/wave-loop/op-discover-wide.json +290 -0
- package/src/__golden__/wave-loop/op-held-on-again.json +176 -0
- package/src/__golden__/wave-loop/op-held-on-before-apply.json +689 -0
- package/src/__golden__/wave-loop/op-held-on.json +1455 -0
- package/src/__golden__/wave-loop/op-hooks-apply-fails.json +673 -0
- package/src/__golden__/wave-loop/op-hooks-fail-after-apply.json +737 -0
- package/src/__golden__/wave-loop/op-hooks-fail-before-apply.json +450 -0
- package/src/__golden__/wave-loop/op-hooks-fail-before-plan.json +97 -0
- package/src/__golden__/wave-loop/op-hooks-held.json +1059 -0
- package/src/__golden__/wave-loop/op-hooks-hold-before-apply.json +693 -0
- package/src/__golden__/wave-loop/op-hooks-pass.json +735 -0
- package/src/__golden__/wave-loop/op-hooks-refused-at-base.json +148 -0
- package/src/__golden__/wave-loop/op-hooks-share.json +1225 -0
- package/src/__golden__/wave-loop/op-outcome-held.json +202 -0
- package/src/__golden__/wave-loop/op-outcome-replan-approved.json +286 -0
- package/src/__golden__/wave-loop/op-outcome-replan-forever.json +823 -0
- package/src/__golden__/wave-loop/op-outcome-replan.json +259 -0
- package/src/__golden__/wave-loop/op-outcome-stood-down.json +196 -0
- package/src/__golden__/wave-loop/op-outcome-unreadable.json +193 -0
- package/src/__golden__/wave-loop/op-overridden.json +508 -0
- package/src/__golden__/wave-loop/op-per-run.json +518 -0
- package/src/__golden__/wave-loop/op-point.json +495 -0
- package/src/__golden__/wave-loop/op-pr-review.json +397 -0
- package/src/__golden__/wave-loop/op-sealed.json +125 -0
- package/src/__golden__/wave-loop/op-spend-change-only.json +168 -0
- package/src/__golden__/wave-loop/op-spend.json +328 -0
- package/src/__golden__/wave-loop/op-wave-commands.json +1808 -0
- package/src/__golden__/wave-loop/op-wide-refused.json +291 -0
- package/src/__golden__/wave-loop/op-wide-waiting.json +231 -0
- package/src/__golden__/wave-loop/op-wide.json +440 -0
- package/src/cli/commands/import-live.test.ts +24 -0
- package/src/cli/commands/import.ts +8 -2
- package/src/cli/handlers/graph.test.ts +23 -0
- package/src/cli/handlers/graph.ts +22 -1
- package/src/cli/handlers/misc.ts +2 -1
- package/src/cli/main.test.ts +5 -0
- package/src/cli/parse-args.ts +3 -0
- package/src/cli/registry.ts +2 -0
- package/src/codegen/publish-order.test.ts +118 -4
- package/src/components/fan-out-run.ts +156 -128
- package/src/graph-ci-edges.test.ts +97 -0
- package/src/graph-detail.ts +9 -3
- package/src/graph-dot.ts +34 -2
- package/src/graph-ir.ts +156 -3
- package/src/graph-lens.ts +10 -1
- package/src/graph-mermaid.ts +44 -2
- package/src/lexicon.ts +23 -1
- package/src/meta/peer-deps.test.ts +39 -0
- package/src/op/op-waves-run.ts +184 -68
- package/src/wave-engine.test.ts +38 -1
- package/src/wave-engine.ts +101 -5
- package/src/wave-loop-golden.test.ts +627 -0
|
@@ -12,14 +12,20 @@
|
|
|
12
12
|
* npm, after the tag.
|
|
13
13
|
*
|
|
14
14
|
* publish-packages.sh now derives the order instead of assuming one, as
|
|
15
|
-
* dependency layers that pack
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* dependency layers that pack in parallel (#3857). This asserts the
|
|
16
|
+
* derivation is actually topological, so the next lexicon that depends on an
|
|
17
|
+
* alphabetically earlier one fails here rather than mid-release.
|
|
18
|
+
*
|
|
19
|
+
* With CHANT_STAGED_PUBLISH=1 (#3903) every package publishes under `next`
|
|
20
|
+
* in one batch instead, and the order that reaches consumers is the order
|
|
21
|
+
* `latest` moves in, which `tag_layers` derives from peer and optional edges.
|
|
22
|
+
* Both modes are checked at the end of this file.
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
25
|
import { describe, expect, it } from "vitest";
|
|
21
26
|
import { execFileSync } from "node:child_process";
|
|
22
|
-
import { readFileSync } from "node:fs";
|
|
27
|
+
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
23
29
|
import { join, dirname } from "node:path";
|
|
24
30
|
import { fileURLToPath } from "node:url";
|
|
25
31
|
|
|
@@ -147,3 +153,111 @@ describe("packages built even when their publish is skipped", () => {
|
|
|
147
153
|
expect(built.has("lexicons/forgejo")).toBe(false);
|
|
148
154
|
});
|
|
149
155
|
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* #3903: a release is staged under `next` and only becomes visible to range
|
|
159
|
+
* consumers when `latest` moves. The #3856 research found that when a
|
|
160
|
+
* lexicon's `latest` showed before core's, pnpm, bun and yarn installed the
|
|
161
|
+
* new lexicon against the old core with the peer unmet, and exited 0. So
|
|
162
|
+
* `latest` moves in waves, each package after everything it peers on.
|
|
163
|
+
*/
|
|
164
|
+
describe("latest tag order", () => {
|
|
165
|
+
const waves = new Map(
|
|
166
|
+
ask("tag_layers").map((line) => {
|
|
167
|
+
const [wave, dir] = line.split("\t");
|
|
168
|
+
return [dir, Number(wave)] as const;
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
const order = publishOrder();
|
|
172
|
+
|
|
173
|
+
it("covers every package that publishes", () => {
|
|
174
|
+
expect([...waves.keys()].sort()).toEqual([...order].sort());
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("moves a package's latest after every package it peers on or optionally depends on", () => {
|
|
178
|
+
const owner = new Map(order.map((d) => [manifest(d).name as string, d]));
|
|
179
|
+
for (const dir of order) {
|
|
180
|
+
const pkg = manifest(dir);
|
|
181
|
+
for (const name of Object.keys({ ...pkg.peerDependencies, ...pkg.optionalDependencies })) {
|
|
182
|
+
const depDir = owner.get(name);
|
|
183
|
+
if (!depDir || depDir === dir) continue;
|
|
184
|
+
expect(
|
|
185
|
+
waves.get(depDir)!,
|
|
186
|
+
`${pkg.name} would move latest in wave ${waves.get(dir)}, no later than ${name} (wave ${waves.get(depDir)}), ` +
|
|
187
|
+
`so a consumer could get the new ${pkg.name} against the old ${name}`,
|
|
188
|
+
).toBeLessThan(waves.get(dir)!);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("moves core and k8s-client first, and no lexicon with them", () => {
|
|
194
|
+
expect(waves.get("packages/core")).toBe(0);
|
|
195
|
+
expect(waves.get("packages/k8s-client")).toBe(0);
|
|
196
|
+
for (const [dir, wave] of waves) {
|
|
197
|
+
if (dir.startsWith("lexicons/")) expect(wave, `${dir} moves latest with core`).toBeGreaterThan(0);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("moves github before the two lexicons that peer on it", () => {
|
|
202
|
+
expect(waves.get("lexicons/github")).toBeLessThan(waves.get("lexicons/forgejo")!);
|
|
203
|
+
expect(waves.get("lexicons/github")).toBeLessThan(waves.get("lexicons/gitlab")!);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Staging is opt-in until every trusted-publisher record allows dist-tag
|
|
210
|
+
* moves (#3904). Off, the script must publish exactly as it did before
|
|
211
|
+
* #3903: straight to `latest`, layer by layer, with no tag moves.
|
|
212
|
+
*/
|
|
213
|
+
describe("publish modes (#3903)", () => {
|
|
214
|
+
const script = readFileSync(join(REPO, "scripts/publish-packages.sh"), "utf8");
|
|
215
|
+
const publishes = script
|
|
216
|
+
.split("\n")
|
|
217
|
+
.filter((line) => !line.trim().startsWith("#") && /\bnpm publish "\$tgz"/.test(line));
|
|
218
|
+
|
|
219
|
+
it("passes --tag next to npm publish only when CHANT_STAGED_PUBLISH is 1", () => {
|
|
220
|
+
expect(publishes.length).toBeGreaterThan(0);
|
|
221
|
+
for (const line of publishes) {
|
|
222
|
+
expect(line).not.toMatch(/--tag next/);
|
|
223
|
+
expect(line).toContain('${TAG_ARGS[@]+"${TAG_ARGS[@]}"}');
|
|
224
|
+
}
|
|
225
|
+
expect(script).toMatch(/^TAG_ARGS=\(\)$/m);
|
|
226
|
+
expect(script).toMatch(/^\[ -n "\$STAGED" \] && TAG_ARGS=\(--tag next\)$/m);
|
|
227
|
+
expect(script).toMatch(/^\[ "\$\{CHANT_STAGED_PUBLISH:-\}" = "1" \] && STAGED=1$/m);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("moves latest with dist-tag only in the staged flow", () => {
|
|
231
|
+
const legacy = script.match(/^if \[ -z "\$STAGED" \]; then$.*?^fi$/ms);
|
|
232
|
+
expect(legacy, "the default publish block").not.toBeNull();
|
|
233
|
+
expect(legacy![0]).toMatch(/wait_visible/);
|
|
234
|
+
expect(legacy![0]).not.toMatch(/dist-tag|move_latest/);
|
|
235
|
+
expect(legacy![0]).toMatch(/^ exit 0$/m);
|
|
236
|
+
expect(script).toMatch(/dist-tag add "\$name@\$version" latest/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// A stub npm whose every answer is an old version, so every package plans
|
|
240
|
+
// to publish. DRY_RUN=plan reads the registry and the graph, builds nothing.
|
|
241
|
+
function plan(env: Record<string, string>): string {
|
|
242
|
+
const bin = mkdtempSync(join(tmpdir(), "publish-plan-"));
|
|
243
|
+
writeFileSync(join(bin, "npm"), "#!/bin/sh\necho 0.0.1\n");
|
|
244
|
+
chmodSync(join(bin, "npm"), 0o755);
|
|
245
|
+
return execFileSync("bash", ["scripts/publish-packages.sh"], {
|
|
246
|
+
cwd: REPO,
|
|
247
|
+
encoding: "utf8",
|
|
248
|
+
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, DRY_RUN: "plan", RUNNER_TEMP: bin, ...env },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
it("plans a layered publish to latest by default", () => {
|
|
253
|
+
const out = plan({ CHANT_STAGED_PUBLISH: "" });
|
|
254
|
+
expect(out).toMatch(/@intentius\/chant \S+: publish \(live: 0\.0\.1\)/);
|
|
255
|
+
expect(out).not.toMatch(/under next|wave \d/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("plans a staged publish and the tag waves with CHANT_STAGED_PUBLISH=1", () => {
|
|
259
|
+
const out = plan({ CHANT_STAGED_PUBLISH: "1" });
|
|
260
|
+
expect(out).toMatch(/@intentius\/chant \S+: publish under next \(latest: 0\.0\.1\)/);
|
|
261
|
+
expect(out).toMatch(/^ wave 0: @intentius\/chant @intentius\/chant-k8s-client$/m);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -31,9 +31,10 @@
|
|
|
31
31
|
* ## Or one approval per wave
|
|
32
32
|
*
|
|
33
33
|
* `waveGate` (#3049) is the other mode. Each wave is planned once the waves
|
|
34
|
-
* before it applied, and its gate binds that wave's set digest.
|
|
35
|
-
* the digest and the gate decision come from the wave engine
|
|
36
|
-
* (`../wave-engine.ts`, #3766), which `chant run wave` uses too
|
|
34
|
+
* before it applied, and its gate binds that wave's set digest. The loop,
|
|
35
|
+
* gate names, the digest and the gate decision come from the wave engine
|
|
36
|
+
* (`../wave-engine.ts`, #3766), which `chant run wave` uses too; this module
|
|
37
|
+
* hands it the steps a fan-out plans, decides and applies a wave with. See
|
|
37
38
|
* {@link runGatedWaves}.
|
|
38
39
|
*/
|
|
39
40
|
|
|
@@ -46,13 +47,13 @@ import {
|
|
|
46
47
|
} from "./driver";
|
|
47
48
|
import type { ReleaseIdentity } from "../telemetry-attribution";
|
|
48
49
|
import { downstreamWithin, remainingFanOut, type FanOutPlan, type FanOutProgress, type FanOutSkip } from "./fan-out";
|
|
49
|
-
import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
50
|
+
import { evaluateGate, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "../op/gate";
|
|
50
51
|
import type { RunProgressEvent } from "./run-progress";
|
|
51
52
|
import type { CapabilityRegistry } from "./capability";
|
|
52
53
|
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
53
54
|
import type { DeployContext } from "./capability";
|
|
54
55
|
import { planWaveComponent, type WaveComponentPlan } from "./wave-plan";
|
|
55
|
-
import { decideWaveGate, waveGateName, waveSetDigest, type WaveMember, type WaveRecord } from "../wave-engine";
|
|
56
|
+
import { decideWaveGate, runWaveLoop, waveGateName, waveSetDigest, type WaveMember, type WaveRecord } from "../wave-engine";
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
59
|
* One gate per wave (#3049), each bound to its wave's set digest. The other
|
|
@@ -392,137 +393,164 @@ async function runGatedWaves(
|
|
|
392
393
|
|
|
393
394
|
options.onProgress?.({ type: "run-start", waves: plan.waves });
|
|
394
395
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
396
|
+
/** What planning one wave gave. */
|
|
397
|
+
interface Planned {
|
|
398
|
+
runnable: string[];
|
|
399
|
+
gateName: string;
|
|
400
|
+
contexts: Map<string, DeployContext>;
|
|
401
|
+
members: WaveMember[];
|
|
402
|
+
plansFor: Map<string, Record<string, unknown>>;
|
|
403
|
+
waveFailures: Array<{ component: string; error?: string }>;
|
|
404
|
+
}
|
|
405
|
+
/** `next`: go on to the next wave. `stop`: the run ends here. */
|
|
406
|
+
type Outcome = "next" | "stop";
|
|
407
|
+
|
|
408
|
+
await runWaveLoop<Planned, { check: Extract<GateCheck, { satisfied: true }>; record: WaveRecord; toRun: string[] }, Outcome>({
|
|
409
|
+
first: 1,
|
|
410
|
+
onward: (_k, outcome) => outcome === "next",
|
|
411
|
+
|
|
412
|
+
async plan({ k: waveNum }) {
|
|
413
|
+
const wave = plan.waves[waveNum - 1];
|
|
414
|
+
if (!wave) return { end: "stop" };
|
|
415
|
+
const runnable = wave.filter((name) => left.has(name) && !blockedBy.has(name));
|
|
416
|
+
if (waveGate.only !== undefined && waveNum < waveGate.only) {
|
|
417
|
+
if (runnable.length > 0) throw new EarlierWaveNotAppliedError(waveGate.only, waveNum, runnable);
|
|
418
|
+
return { end: "next" };
|
|
418
419
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
420
|
+
if (waveGate.only !== undefined && waveNum > waveGate.only) return { end: "stop" };
|
|
421
|
+
if (runnable.length === 0) return { end: "next" };
|
|
422
|
+
|
|
423
|
+
const gateName = waveGateName(waveGate.gate, waveNum);
|
|
424
|
+
const contexts = new Map(runnable.map((name) => [name, fanOutContext(options, name)]));
|
|
425
|
+
const planned = await Promise.all(
|
|
426
|
+
runnable.map((name) => planComponent(byName.get(name)!, contexts.get(name)!, componentOutputs)),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const members: WaveMember[] = [];
|
|
430
|
+
const plansFor = new Map<string, Record<string, unknown>>();
|
|
431
|
+
const waveFailures: Array<{ component: string; error?: string }> = [];
|
|
432
|
+
for (const p of planned) {
|
|
433
|
+
if ("error" in p) {
|
|
434
|
+
waveFailures.push({ component: p.component, error: p.error });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
members.push(...p.members);
|
|
438
|
+
plansFor.set(p.component, p.plans);
|
|
439
|
+
}
|
|
440
|
+
return { go: { runnable, gateName, contexts, members, plansFor, waveFailures } };
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
digest(_at, { members, plansFor, waveFailures }) {
|
|
444
|
+
try {
|
|
445
|
+
// A wave where nothing planned has no set, and so no digest.
|
|
446
|
+
return members.length > 0 ? waveSetDigest(members) : "";
|
|
447
|
+
} catch (err) {
|
|
448
|
+
// Two components planning the same root: there is no one plan for it.
|
|
449
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
450
|
+
for (const name of plansFor.keys()) waveFailures.push({ component: name, error });
|
|
451
|
+
plansFor.clear();
|
|
452
|
+
return "";
|
|
453
|
+
}
|
|
454
|
+
},
|
|
439
455
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
digest,
|
|
447
|
-
members: [...members].sort((a, b) => a.member.localeCompare(b.member)),
|
|
448
|
-
status: "failed",
|
|
449
|
-
...(waveFailures.length > 0 ? { failed: waveFailures } : {}),
|
|
450
|
-
};
|
|
451
|
-
if (toRun.length === 0) {
|
|
452
|
-
waves.push(record);
|
|
453
|
-
options.onWaveSettled?.(record);
|
|
454
|
-
continue;
|
|
455
|
-
}
|
|
456
|
+
async decide({ k: waveNum }, { runnable, gateName, members, plansFor, waveFailures }, digest) {
|
|
457
|
+
for (const f of waveFailures) {
|
|
458
|
+
failed.push(f.component);
|
|
459
|
+
results.push(planFailure(f.component, f.error ?? "plan failed"));
|
|
460
|
+
markBlocked(f.component);
|
|
461
|
+
}
|
|
456
462
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
// would read outputs this wave has not written.
|
|
468
|
-
const stopped: WaveRecord = {
|
|
469
|
-
...record,
|
|
470
|
-
status: "gated",
|
|
471
|
-
...(check.mismatch?.approved ? { approved: check.mismatch.approved } : {}),
|
|
463
|
+
const toRun = runnable.filter((name) => plansFor.has(name));
|
|
464
|
+
const record: WaveRecord = {
|
|
465
|
+
wave: waveNum,
|
|
466
|
+
op: waveGate.op,
|
|
467
|
+
gate: gateName,
|
|
468
|
+
components: [...runnable].sort(),
|
|
469
|
+
digest,
|
|
470
|
+
members: [...members].sort((a, b) => a.member.localeCompare(b.member)),
|
|
471
|
+
status: "failed",
|
|
472
|
+
...(waveFailures.length > 0 ? { failed: waveFailures } : {}),
|
|
472
473
|
};
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
options.onProgress?.({ type: "wave-start", wave: waveNum, components: toRun });
|
|
480
|
-
const waveResults = await Promise.all(
|
|
481
|
-
toRun.map(async (name) => {
|
|
482
|
-
options.onProgress?.({ type: "component-start", wave: waveNum, component: name });
|
|
483
|
-
const plans = plansFor.get(name)!;
|
|
484
|
-
const ctx = { ...contexts.get(name)!, ...(Object.keys(plans).length > 0 ? { plans } : {}) };
|
|
485
|
-
const result = await runComponentDeploy(byName.get(name)!, ctx, registry, componentOutputs, options.onProgress, gates);
|
|
486
|
-
options.onProgress?.({
|
|
487
|
-
type: "component-done",
|
|
488
|
-
wave: waveNum,
|
|
489
|
-
component: name,
|
|
490
|
-
status: result.status === "fail" ? "failed" : result.status,
|
|
491
|
-
});
|
|
492
|
-
options.onComponentSettled?.(result, componentOutputs[name]);
|
|
493
|
-
return result;
|
|
494
|
-
}),
|
|
495
|
-
);
|
|
496
|
-
results.push(...waveResults);
|
|
474
|
+
if (toRun.length === 0) {
|
|
475
|
+
waves.push(record);
|
|
476
|
+
options.onWaveSettled?.(record);
|
|
477
|
+
return { end: "next" };
|
|
478
|
+
}
|
|
497
479
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
480
|
+
const check = await decideWaveGate(gates.port, {
|
|
481
|
+
op: waveGate.op,
|
|
482
|
+
gate: gateName,
|
|
483
|
+
digest,
|
|
484
|
+
description: waveGate.description ?? `wave ${waveNum} of ${plan.waves.length}: ${toRun.join(", ")}`,
|
|
485
|
+
...(waveGate.timeout ? { timeout: waveGate.timeout } : {}),
|
|
486
|
+
...(options.now ? { now: options.now } : {}),
|
|
487
|
+
});
|
|
488
|
+
if (!check.satisfied) {
|
|
489
|
+
// Nothing in this wave ran, and no later wave is planned: its plan
|
|
490
|
+
// would read outputs this wave has not written.
|
|
491
|
+
const stopped: WaveRecord = {
|
|
492
|
+
...record,
|
|
493
|
+
status: "gated",
|
|
494
|
+
...(check.mismatch?.approved ? { approved: check.mismatch.approved } : {}),
|
|
495
|
+
};
|
|
496
|
+
waves.push(stopped);
|
|
497
|
+
options.onWaveSettled?.(stopped);
|
|
498
|
+
gatedFact = check.pending;
|
|
499
|
+
return { end: "stop" };
|
|
502
500
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
501
|
+
return { go: { check, record, toRun } };
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
async apply({ k: waveNum }, { contexts, plansFor, waveFailures }, { check, record, toRun }) {
|
|
505
|
+
options.onProgress?.({ type: "wave-start", wave: waveNum, components: toRun });
|
|
506
|
+
const waveResults = await Promise.all(
|
|
507
|
+
toRun.map(async (name) => {
|
|
508
|
+
options.onProgress?.({ type: "component-start", wave: waveNum, component: name });
|
|
509
|
+
const plans = plansFor.get(name)!;
|
|
510
|
+
const ctx = { ...contexts.get(name)!, ...(Object.keys(plans).length > 0 ? { plans } : {}) };
|
|
511
|
+
const result = await runComponentDeploy(byName.get(name)!, ctx, registry, componentOutputs, options.onProgress, gates);
|
|
512
|
+
options.onProgress?.({
|
|
513
|
+
type: "component-done",
|
|
514
|
+
wave: waveNum,
|
|
515
|
+
component: name,
|
|
516
|
+
status: result.status === "fail" ? "failed" : result.status,
|
|
517
|
+
});
|
|
518
|
+
options.onComponentSettled?.(result, componentOutputs[name]);
|
|
519
|
+
return result;
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
results.push(...waveResults);
|
|
523
|
+
|
|
524
|
+
for (const result of waveResults) {
|
|
525
|
+
if (result.status === "ok") {
|
|
526
|
+
completed.push(result.component);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (result.status === "fail") {
|
|
530
|
+
failed.push(result.component);
|
|
531
|
+
waveFailures.push({ component: result.component });
|
|
532
|
+
} else {
|
|
533
|
+
gatedFact ??= result.gate;
|
|
534
|
+
}
|
|
535
|
+
markBlocked(result.component);
|
|
508
536
|
}
|
|
509
|
-
markBlocked(result.component);
|
|
510
|
-
}
|
|
511
537
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
538
|
+
const settled: WaveRecord = {
|
|
539
|
+
...record,
|
|
540
|
+
status: waveFailures.length > 0 ? "failed" : "applied",
|
|
541
|
+
approvedBy: check.resolution.resolvedBy,
|
|
542
|
+
...(waveFailures.length > 0 ? { failed: waveFailures } : {}),
|
|
543
|
+
};
|
|
544
|
+
waves.push(settled);
|
|
545
|
+
options.onWaveSettled?.(settled);
|
|
546
|
+
options.onProgress?.({
|
|
547
|
+
type: "wave-done",
|
|
548
|
+
wave: waveNum,
|
|
549
|
+
status: waveFailures.length > 0 ? "failed" : waveResults.some((r) => r.status === "gated") ? "gated" : "ok",
|
|
550
|
+
});
|
|
551
|
+
return "next";
|
|
552
|
+
},
|
|
553
|
+
});
|
|
526
554
|
|
|
527
555
|
const blocked: FanOutSkip[] = [...blockedBy.entries()]
|
|
528
556
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { applyLexiconGraphEdges, type GraphIR } from "./graph-ir";
|
|
3
|
+
import { toMermaid } from "./graph-mermaid";
|
|
4
|
+
import { toDot } from "./graph-dot";
|
|
5
|
+
import { applyDetail } from "./graph-detail";
|
|
6
|
+
import { applyLens, parseLens } from "./graph-lens";
|
|
7
|
+
|
|
8
|
+
// #3910: CI lexicons report their dependency strings through `graphEdges`, and
|
|
9
|
+
// core merges them, keeps the unresolved ones and draws both.
|
|
10
|
+
|
|
11
|
+
const base: GraphIR = {
|
|
12
|
+
nodes: [
|
|
13
|
+
{ id: "ci", kind: "GitHub::Actions::Workflow", lexicon: "github", attrs: {} },
|
|
14
|
+
{ id: "build", kind: "GitHub::Actions::Job", lexicon: "github", attrs: {} },
|
|
15
|
+
{ id: "test", kind: "GitHub::Actions::Job", lexicon: "github", attrs: {} },
|
|
16
|
+
{ id: "shared", kind: "GitHub::Actions::Workflow", lexicon: "github", attrs: {} },
|
|
17
|
+
],
|
|
18
|
+
// The core walk already found `test` holding the build Job under `needs`.
|
|
19
|
+
edges: [{ from: "test", to: "build", kind: "ref", viaAttr: "needs" }],
|
|
20
|
+
groups: { byLexicon: { github: ["build", "ci", "shared", "test"] } },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const merged = applyLexiconGraphEdges(base, [{
|
|
24
|
+
references: [
|
|
25
|
+
{ from: "test", to: "build", kind: "needs", viaAttr: "needs" },
|
|
26
|
+
{ from: "test", to: "shared", kind: "calls", viaAttr: "uses", crossPipeline: true },
|
|
27
|
+
{ from: "test", to: "lint", kind: "needs", viaAttr: "needs", dangling: true },
|
|
28
|
+
{ from: "build", to: "nowhere", kind: "needs", viaAttr: "needs" },
|
|
29
|
+
{ from: "ghost", to: "build", kind: "needs" },
|
|
30
|
+
],
|
|
31
|
+
pipelines: { ci: ["ci", "build", "test", "ghost"], shared: ["shared"] },
|
|
32
|
+
}]);
|
|
33
|
+
|
|
34
|
+
describe("applyLexiconGraphEdges (#3910)", () => {
|
|
35
|
+
test("a lexicon edge replaces the core ref edge on the same from/to/viaAttr", () => {
|
|
36
|
+
expect(merged.edges).toEqual([
|
|
37
|
+
{ from: "test", to: "build", kind: "needs", viaAttr: "needs" },
|
|
38
|
+
{ from: "test", to: "shared", kind: "calls", viaAttr: "uses", crossPipeline: true },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a dangling reference, or one whose target is not a node, is kept on ir.dangling", () => {
|
|
43
|
+
expect(merged.dangling).toEqual([
|
|
44
|
+
{ from: "build", kind: "needs", path: "needs", value: "nowhere" },
|
|
45
|
+
{ from: "test", kind: "needs", path: "needs", value: "lint" },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("pipelines land on groups.byPipeline, restricted to nodes", () => {
|
|
50
|
+
expect(merged.groups.byPipeline).toEqual({ ci: ["build", "ci", "test"], shared: ["shared"] });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no report leaves the IR as it was", () => {
|
|
54
|
+
expect(applyLexiconGraphEdges(base, [])).toBe(base);
|
|
55
|
+
expect(applyLexiconGraphEdges(base, [{ references: [] }])).toBe(base);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("renderers draw CI edges (#3910)", () => {
|
|
60
|
+
test("mermaid: kind labels, dashed cross-pipeline edges, pipeline subgraphs, dangling stubs", () => {
|
|
61
|
+
const out = toMermaid(merged);
|
|
62
|
+
expect(out).toContain('test -->|"needs"| build');
|
|
63
|
+
expect(out).toContain('test -.->|"calls (uses)"| shared');
|
|
64
|
+
expect(out).toContain('subgraph pipe_github_ci["ci"]');
|
|
65
|
+
expect(out).toContain('subgraph pipe_github_shared["shared"]');
|
|
66
|
+
expect(out).toContain('dangling_lint(["lint<br/>(not declared)"])');
|
|
67
|
+
expect(out).toContain('test -.->|"needs"| dangling_lint');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("dot: the same, as nested clusters and dashed edges", () => {
|
|
71
|
+
const out = toDot(merged);
|
|
72
|
+
expect(out).toContain('"test" -> "build" [label="needs"];');
|
|
73
|
+
expect(out).toContain('"test" -> "shared" [label="calls (uses)", style=dashed];');
|
|
74
|
+
expect(out).toContain('subgraph "cluster_github_pipeline_ci" {');
|
|
75
|
+
expect(out).toContain('"dangling:lint" [label="lint\\n(not declared)", shape=box, style="rounded,dashed"];');
|
|
76
|
+
expect(out).toContain('"test" -> "dangling:lint" [label="needs", style=dashed];');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a typed ref edge renders as before", () => {
|
|
80
|
+
const out = toMermaid({ ...base, edges: [{ from: "test", to: "build", kind: "ref", viaAttr: "needs" }] });
|
|
81
|
+
expect(out).toContain('test -->|"needs"| build');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("detail and lens keep CI edges (#3910)", () => {
|
|
86
|
+
test("the composite tier keeps the kind and the cross-pipeline mark, and the dangling list", () => {
|
|
87
|
+
const t1 = applyDetail(merged, 1);
|
|
88
|
+
expect(t1.edges).toContainEqual({ from: "test", to: "shared", kind: "calls", viaAttr: "uses", crossPipeline: true });
|
|
89
|
+
expect(t1.dangling).toHaveLength(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a lens keeps the dangling references and pipelines of the nodes it keeps", () => {
|
|
93
|
+
const focused = applyLens(merged, parseLens("blast:shared", { down: true }));
|
|
94
|
+
expect(focused.dangling).toEqual([{ from: "test", kind: "needs", path: "needs", value: "lint" }]);
|
|
95
|
+
expect(focused.groups.byPipeline).toEqual({ ci: ["test"], shared: ["shared"] });
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/graph-detail.ts
CHANGED
|
@@ -159,10 +159,13 @@ function toComposites(ir: GraphIR): GraphIR {
|
|
|
159
159
|
if (from === to) continue; // edge internal to a composite
|
|
160
160
|
// A property label only makes sense when neither endpoint was collapsed.
|
|
161
161
|
const collapsed = from !== e.from || to !== e.to;
|
|
162
|
+
// The kind survives the collapse (#3910): a job's `needs` into a
|
|
163
|
+
// collapsed composite is still a `needs`, not a typed reference.
|
|
164
|
+
const cross = e.crossPipeline ? { crossPipeline: true } : {};
|
|
162
165
|
const edge: IREdge = collapsed
|
|
163
|
-
? { from, to, kind:
|
|
164
|
-
: { from, to, kind:
|
|
165
|
-
const key = `${from}\0${to}\0${edge.viaAttr ?? ""}`;
|
|
166
|
+
? { from, to, kind: e.kind, ...cross }
|
|
167
|
+
: { from, to, kind: e.kind, viaAttr: e.viaAttr, ...cross };
|
|
168
|
+
const key = `${from}\0${to}\0${edge.viaAttr ?? ""}\0${collapsed ? e.kind : ""}`;
|
|
166
169
|
if (!seen.has(key)) seen.set(key, edge);
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -173,11 +176,14 @@ function toComposites(ir: GraphIR): GraphIR {
|
|
|
173
176
|
// not just the base + ATTRIBUTES. Filter to handles whose node survived.
|
|
174
177
|
const surviving = new Set(nodes.map((n) => n.id));
|
|
175
178
|
const imports = ir.imports?.filter((i) => surviving.has(i.node));
|
|
179
|
+
// Unresolved references (#3910) stay with whichever node now holds them.
|
|
180
|
+
const dangling = ir.dangling?.map((d) => ({ ...d, from: idMap.get(d.from) ?? d.from }));
|
|
176
181
|
return {
|
|
177
182
|
nodes,
|
|
178
183
|
edges: sortEdges([...seen.values()]),
|
|
179
184
|
groups: { byLexicon: byLexiconOf(nodes) },
|
|
180
185
|
...(imports && imports.length ? { imports } : {}),
|
|
186
|
+
...(dangling && dangling.length ? { dangling } : {}),
|
|
181
187
|
};
|
|
182
188
|
}
|
|
183
189
|
|
package/src/graph-dot.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GraphIR, IRNode, IREdge } from "./graph-ir";
|
|
2
|
+
import { edgeLabel } from "./graph-mermaid";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Render the graph IR as Graphviz DOT. Pure text — no `dot` needed to produce
|
|
@@ -14,17 +15,36 @@ export function toDot(ir: GraphIR): string {
|
|
|
14
15
|
|
|
15
16
|
// Cluster by wave (component graph) when present, else by lexicon (entity graph).
|
|
16
17
|
const clusters = ir.groups.byWave ?? ir.groups.byLexicon;
|
|
18
|
+
// CI pipelines (#3910) nest as clusters inside their lexicon's cluster.
|
|
19
|
+
const pipelineOf = new Map<string, string>();
|
|
20
|
+
for (const [name, members] of Object.entries(ir.groups.byPipeline ?? {})) {
|
|
21
|
+
for (const id of members) if (!pipelineOf.has(id)) pipelineOf.set(id, name);
|
|
22
|
+
}
|
|
17
23
|
const grouped = new Set<string>();
|
|
18
24
|
if (clusters) {
|
|
19
25
|
for (const [name, members] of Object.entries(clusters)) {
|
|
20
26
|
lines.push(` subgraph ${q(`cluster_${name}`)} {`);
|
|
21
27
|
lines.push(` label=${q(name)};`);
|
|
28
|
+
const nested = new Map<string, IRNode[]>();
|
|
22
29
|
for (const id of members) {
|
|
23
30
|
const node = ir.nodes.find((n) => n.id === id);
|
|
24
31
|
if (!node) continue;
|
|
25
32
|
grouped.add(id);
|
|
33
|
+
const pipeline = pipelineOf.get(id);
|
|
34
|
+
if (pipeline !== undefined) {
|
|
35
|
+
const arr = nested.get(pipeline) ?? [];
|
|
36
|
+
arr.push(node);
|
|
37
|
+
nested.set(pipeline, arr);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
26
40
|
lines.push(` ${nodeLine(node)}`);
|
|
27
41
|
}
|
|
42
|
+
for (const [pipeline, nodes] of [...nested].sort(([a], [b]) => a.localeCompare(b))) {
|
|
43
|
+
lines.push(` subgraph ${q(`cluster_${name}_pipeline_${pipeline}`)} {`);
|
|
44
|
+
lines.push(` label=${q(pipeline)};`);
|
|
45
|
+
for (const node of nodes) lines.push(` ${nodeLine(node)}`);
|
|
46
|
+
lines.push(" }");
|
|
47
|
+
}
|
|
28
48
|
lines.push(" }");
|
|
29
49
|
}
|
|
30
50
|
}
|
|
@@ -35,6 +55,17 @@ export function toDot(ir: GraphIR): string {
|
|
|
35
55
|
|
|
36
56
|
for (const e of ir.edges) lines.push(` ${edgeLine(e)}`);
|
|
37
57
|
|
|
58
|
+
// Unresolved references (#3910): a dashed edge to a dashed stub node.
|
|
59
|
+
const stubs = new Set<string>();
|
|
60
|
+
for (const d of ir.dangling ?? []) {
|
|
61
|
+
const stub = `dangling:${d.value}`;
|
|
62
|
+
if (!stubs.has(stub)) {
|
|
63
|
+
stubs.add(stub);
|
|
64
|
+
lines.push(` ${q(stub)} [label=${q(`${d.value}\n(not declared)`)}, shape=box, style="rounded,dashed"];`);
|
|
65
|
+
}
|
|
66
|
+
lines.push(` ${q(d.from)} -> ${q(stub)} [label=${q(d.kind)}, style=dashed];`);
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
lines.push("}");
|
|
39
70
|
return lines.join("\n") + "\n";
|
|
40
71
|
}
|
|
@@ -47,8 +78,9 @@ function nodeLine(node: IRNode): string {
|
|
|
47
78
|
function edgeLine(e: IREdge): string {
|
|
48
79
|
const from = q(e.from);
|
|
49
80
|
const to = q(e.to);
|
|
50
|
-
const label =
|
|
51
|
-
|
|
81
|
+
const label = edgeLabel(e);
|
|
82
|
+
const attrs = [label ? `label=${q(label)}` : "", e.crossPipeline ? "style=dashed" : ""].filter(Boolean);
|
|
83
|
+
return attrs.length ? `${from} -> ${to} [${attrs.join(", ")}];` : `${from} -> ${to};`;
|
|
52
84
|
}
|
|
53
85
|
|
|
54
86
|
/** Quote a DOT identifier/label. Graphviz accepts any string in double quotes;
|