@intentius/chant 0.124.18 → 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.
Files changed (41) hide show
  1. package/dist/cli/commands/import.d.ts +2 -0
  2. package/dist/cli/commands/import.d.ts.map +1 -1
  3. package/dist/cli/commands/import.js +6 -2
  4. package/dist/cli/handlers/graph.js +24 -1
  5. package/dist/cli/handlers/misc.d.ts.map +1 -1
  6. package/dist/cli/handlers/misc.js +2 -1
  7. package/dist/cli/parse-args.d.ts.map +1 -1
  8. package/dist/cli/parse-args.js +4 -0
  9. package/dist/cli/registry.d.ts +2 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/graph-detail.d.ts.map +1 -1
  12. package/dist/graph-detail.js +9 -3
  13. package/dist/graph-dot.d.ts.map +1 -1
  14. package/dist/graph-dot.js +36 -2
  15. package/dist/graph-ir.d.ts +98 -3
  16. package/dist/graph-ir.d.ts.map +1 -1
  17. package/dist/graph-ir.js +71 -0
  18. package/dist/graph-lens.js +12 -1
  19. package/dist/graph-mermaid.d.ts +5 -1
  20. package/dist/graph-mermaid.d.ts.map +1 -1
  21. package/dist/graph-mermaid.js +46 -2
  22. package/dist/lexicon.d.ts +22 -1
  23. package/dist/lexicon.d.ts.map +1 -1
  24. package/dist/wave-runtime.mjs +169 -169
  25. package/package.json +1 -1
  26. package/src/cli/commands/import-live.test.ts +24 -0
  27. package/src/cli/commands/import.ts +8 -2
  28. package/src/cli/handlers/graph.test.ts +23 -0
  29. package/src/cli/handlers/graph.ts +22 -1
  30. package/src/cli/handlers/misc.ts +2 -1
  31. package/src/cli/main.test.ts +5 -0
  32. package/src/cli/parse-args.ts +3 -0
  33. package/src/cli/registry.ts +2 -0
  34. package/src/codegen/publish-order.test.ts +118 -4
  35. package/src/graph-ci-edges.test.ts +97 -0
  36. package/src/graph-detail.ts +9 -3
  37. package/src/graph-dot.ts +34 -2
  38. package/src/graph-ir.ts +156 -3
  39. package/src/graph-lens.ts +10 -1
  40. package/src/graph-mermaid.ts +44 -2
  41. package/src/lexicon.ts +23 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.124.18",
3
+ "version": "0.124.19",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -143,6 +143,30 @@ describe("liveImportFromPlugins (#114)", () => {
143
143
  expect(result.error).toContain("No resources exported");
144
144
  });
145
145
 
146
+ // #3911 — an exporter that refuses (the sql lexicon naming objects it cannot
147
+ // declare) is the import's error, not a warning behind "No resources exported".
148
+ test("an exporter's failure is the error when nothing else exported, and --skip-undeclarable reaches it", async () => {
149
+ let seen: unknown;
150
+ const refusing: LexiconPlugin = {
151
+ ...fakeExporter("sql", sampleIR),
152
+ async exportResources(opts: { skipUndeclarable?: boolean }) {
153
+ seen = opts.skipUndeclarable;
154
+ if (!opts.skipUndeclarable) throw new Error("the server has an object of a kind chant cannot declare:\n - aggregate app.total(integer)");
155
+ return { ...sampleIR, warnings: ["aggregate app.total(integer) is left out (--skip-undeclarable)"] };
156
+ },
157
+ };
158
+ const failed = await liveImportFromPlugins([refusing], { environment: "prod", output: outputDir, force: true });
159
+ expect(failed.success).toBe(false);
160
+ expect(failed.error).toContain("sql: live export failed");
161
+ expect(failed.error).toContain(" - aggregate app.total(integer)");
162
+ expect(seen).toBeUndefined();
163
+
164
+ const skipped = await liveImportFromPlugins([refusing], { environment: "prod", output: outputDir, force: true, skipUndeclarable: true });
165
+ expect(skipped.success).toBe(true);
166
+ expect(seen).toBe(true);
167
+ expect(skipped.warnings).toContain("aggregate app.total(integer) is left out (--skip-undeclarable)");
168
+ });
169
+
146
170
  // #932 — a multi-stack reconcile imports each stack from its own live
147
171
  // CloudFormation stack; the `stack` option must reach `exportResources` so
148
172
  // the right stack is queried (not one env-named stack for all of them).
@@ -645,6 +645,8 @@ export interface LiveImportOptions {
645
645
  owned?: boolean;
646
646
  /** Keep server-defaulted fields instead of stripping to declared shape. */
647
647
  verbatim?: boolean;
648
+ /** Import what can be declared, and warn about the live objects no declaration can hold instead of failing. */
649
+ skipUndeclarable?: boolean;
648
650
  }
649
651
 
650
652
  /**
@@ -745,6 +747,8 @@ export async function liveImportFromPlugins(
745
747
  // Collect IR from every exporter, tagging which lexicon produced output.
746
748
  let irParts: ExportedTemplate[] = [];
747
749
  let generatorLexicon: LexiconPlugin | undefined;
750
+ // An exporter that fails (an sql import refusing objects it cannot declare, #3911) is the error when nothing else exported.
751
+ const failures: string[] = [];
748
752
  for (const plugin of exporters) {
749
753
  let ir: ExportedTemplate;
750
754
  try {
@@ -754,9 +758,10 @@ export async function liveImportFromPlugins(
754
758
  selector: options.selector,
755
759
  owned: options.owned,
756
760
  verbatim: options.verbatim,
761
+ ...(options.skipUndeclarable ? { skipUndeclarable: true } : {}),
757
762
  });
758
763
  } catch (err) {
759
- warnings.push(`${plugin.name}: live export failed — ${err instanceof Error ? err.message : String(err)}`);
764
+ failures.push(`${plugin.name}: live export failed — ${err instanceof Error ? err.message : String(err)}`);
760
765
  continue;
761
766
  }
762
767
  // What the exporter left out or could not carry (#3336).
@@ -771,9 +776,10 @@ export async function liveImportFromPlugins(
771
776
  success: false,
772
777
  generatedFiles: [],
773
778
  warnings,
774
- error: `No resources exported from environment "${options.environment}".`,
779
+ error: failures.length > 0 ? failures.join("\n") : `No resources exported from environment "${options.environment}".`,
775
780
  };
776
781
  }
782
+ warnings.push(...failures);
777
783
 
778
784
  if (exporters.length > 1 && irParts.length > 1) {
779
785
  warnings.push("Multiple lexicons exported resources; generated with the first. Use --lexicon to target one.");
@@ -249,6 +249,29 @@ describe("runGraph", () => {
249
249
  expect(JSON.parse(stdoutBuf.join("\n")).meta).toEqual({ collector: { pipelines: [] } });
250
250
  });
251
251
 
252
+ // #3910 — a lexicon's graphEdges answer becomes edges and dangling refs.
253
+ test("--format ir carries what a lexicon's graphEdges reports", async () => {
254
+ lintClean(); discovered();
255
+ loadPluginsMock.mockResolvedValue([{
256
+ name: "gitlab",
257
+ serializer: {},
258
+ graphEdges: () => ({
259
+ references: [
260
+ { from: "pod", to: "vpc", kind: "needs", viaAttr: "needs" },
261
+ { from: "pod", to: "group/other", kind: "triggers", viaAttr: "trigger", crossPipeline: true, dangling: true },
262
+ ],
263
+ pipelines: { ".gitlab-ci.yml": ["pod", "vpc"] },
264
+ }),
265
+ }, { name: "broken", serializer: {}, graphEdges: () => { throw new Error("boom"); } }]);
266
+ resolveLexMock.mockResolvedValue(["gitlab", "broken"]);
267
+ const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
268
+ expect(exit).toBe(0);
269
+ const ir = JSON.parse(stdoutBuf.join("\n"));
270
+ expect(ir.edges).toContainEqual({ from: "pod", to: "vpc", kind: "needs", viaAttr: "needs" });
271
+ expect(ir.dangling).toEqual([{ from: "pod", kind: "triggers", path: "trigger", value: "group/other", crossPipeline: true }]);
272
+ expect(ir.groups.byPipeline).toEqual({ ".gitlab-ci.yml": ["pod", "vpc"] });
273
+ });
274
+
252
275
  test("--format ir has no meta when no lexicon reports anything", async () => {
253
276
  lintClean(); discovered();
254
277
  loadPluginsMock.mockResolvedValue([{ name: "gcp", serializer: {}, graphMeta: () => undefined }, { name: "k8s", serializer: {} }]);
@@ -2,7 +2,7 @@ import { dirname, resolve } from "node:path";
2
2
  import { discoverOps } from "../../op/discover";
3
3
  import { discover } from "../../discovery/index";
4
4
  import { partitionByLexicon, computeStackGraph, build, mergeBuildRootEntities } from "../../build";
5
- import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, GRAPH_IR_VERSION, type GraphIR, type IRPipeline, type LiveObservation } from "../../graph-ir";
5
+ import { applyLexiconGraphEdges, buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, GRAPH_IR_VERSION, type GraphIR, type IRPipeline, type LexiconGraphEdges, type LiveObservation } from "../../graph-ir";
6
6
  import { behaviourOverlay } from "../../behaviour-overlay";
7
7
  import type { SerializerResult } from "../../serializer";
8
8
  import { applyBehaviourOverlay, behaviourRequestFromIr } from "../../behaviour-graph";
@@ -726,6 +726,26 @@ function withGraphMeta(ir: GraphIR, plugins: LexiconPlugin[], entities: Map<stri
726
726
  return Object.keys(added).length === 0 ? ir : { ...ir, meta: { ...(ir.meta ?? {}), ...added } };
727
727
  }
728
728
 
729
+ /**
730
+ * Add the edges the lexicons' `graphEdges` hooks resolve (#3910): CI
731
+ * dependencies written as strings (`needs`, `uses:`, `trigger:`, `include:`,
732
+ * `extends`), which the AttrRef walk in `buildGraphIr` cannot see. A hook that
733
+ * throws costs its own edges and a warning, not the graph.
734
+ */
735
+ function withLexiconEdges(ir: GraphIR, plugins: LexiconPlugin[], entities: Map<string, import("../../declarable").Declarable>): GraphIR {
736
+ const reports: LexiconGraphEdges[] = [];
737
+ for (const p of plugins) {
738
+ if (!p.graphEdges) continue;
739
+ try {
740
+ const report = p.graphEdges(entities);
741
+ if (report) reports.push(report);
742
+ } catch (err) {
743
+ console.error(formatWarning({ message: `the ${p.name} lexicon's graphEdges failed (${err instanceof Error ? err.message : String(err)}); its edges are left out` }));
744
+ }
745
+ }
746
+ return reports.length === 0 ? ir : applyLexiconGraphEdges(ir, reports);
747
+ }
748
+
729
749
  /** Returned instead of a graph when the prediction cannot be attempted honestly. */
730
750
  const REFUSED = Symbol("behaviour-refused");
731
751
 
@@ -867,6 +887,7 @@ async function runGraphView(
867
887
  const plugins = ctx.plugins.length > 0
868
888
  ? ctx.plugins
869
889
  : await loadPlugins(await projectLexiconsOrNone(projectPath));
890
+ ir = withLexiconEdges(ir, plugins as LexiconPlugin[], result.entities);
870
891
  const predicted = await predictOntoIr(ir, plugins as LexiconPlugin[], ctx.args, {
871
892
  // No environment on the declared path: the file is not deployed anywhere
872
893
  // yet, and naming one would claim it was.
@@ -349,7 +349,7 @@ export async function runImport(ctx: CommandContext): Promise<number> {
349
349
  const { config } = await loadChantConfig(resolve("."));
350
350
  if (config.stacks && config.stacks.length > 0) {
351
351
  const perStack = await importFromLiveStacks(
352
- { environment: args.migrateFrom, lexicon: args.lexicon, force: args.force, selector, owned: args.owned, verbatim: args.verbatim },
352
+ { environment: args.migrateFrom, lexicon: args.lexicon, force: args.force, selector, owned: args.owned, verbatim: args.verbatim, skipUndeclarable: args.skipUndeclarable },
353
353
  config.stacks,
354
354
  );
355
355
  let anyFailure = false;
@@ -369,6 +369,7 @@ export async function runImport(ctx: CommandContext): Promise<number> {
369
369
  selector,
370
370
  owned: args.owned,
371
371
  verbatim: args.verbatim,
372
+ skipUndeclarable: args.skipUndeclarable,
372
373
  });
373
374
 
374
375
  printImportResult(result);
@@ -16,6 +16,11 @@ describe("parseArgs", () => {
16
16
  expect(() => parseArgs(["graph", "--traffic"])).toThrow(/--traffic needs a level/);
17
17
  });
18
18
 
19
+ test("--skip-undeclarable reaches live import (#3911)", () => {
20
+ expect(parseArgs(["import", "--from", "prod"]).skipUndeclarable).toBeUndefined();
21
+ expect(parseArgs(["import", "--from", "prod", "--skip-undeclarable"]).skipUndeclarable).toBe(true);
22
+ });
23
+
19
24
  test("--fold and --no-fold set the tri-state fold option (#1134)", () => {
20
25
  expect(parseArgs(["build", "src"]).fold).toBeUndefined();
21
26
  expect(parseArgs(["build", "src", "--fold"]).fold).toBe(true);
@@ -33,6 +33,7 @@ const BOOLEAN_FLAGS = new Set([
33
33
  "--explain",
34
34
  "--owned",
35
35
  "--verbatim",
36
+ "--skip-undeclarable",
36
37
  "--apply-rewrites",
37
38
  "--write",
38
39
  "--strict",
@@ -216,6 +217,8 @@ export function parseArgs(args: string[]): ParsedArgs {
216
217
  if (!result.namespace || result.namespace.startsWith("-")) throw new Error("--namespace needs a namespace: --namespace <ns>");
217
218
  } else if (arg === "--verbatim") {
218
219
  result.verbatim = true;
220
+ } else if (arg === "--skip-undeclarable") {
221
+ result.skipUndeclarable = true;
219
222
  } else if (arg === "--state") {
220
223
  result.statePath = args[++i];
221
224
  } else if (arg === "--select") {
@@ -158,6 +158,8 @@ export interface ParsedArgs {
158
158
  confirmProd?: boolean;
159
159
  /** `chant import --verbatim` — keep server-defaulted fields in live import */
160
160
  verbatim?: boolean;
161
+ /** `chant import --skip-undeclarable` — import what can be declared and warn about the live objects that cannot, instead of failing */
162
+ skipUndeclarable?: boolean;
161
163
  /** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
162
164
  src?: string;
163
165
  /** `--env <name>` — active environment: sets CHANT_ENV so env-aware source
@@ -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 and publish in parallel (#3857). This asserts
16
- * the derivation is actually topological, so the next lexicon that depends on
17
- * an alphabetically earlier one fails here rather than mid-release.
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
+ });
@@ -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
+ });
@@ -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: "ref" }
164
- : { from, to, kind: "ref", viaAttr: e.viaAttr };
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 = [e.viaAttr, e.toAttr].filter(Boolean).join(" → ");
51
- return label ? `${from} -> ${to} [label=${q(label)}];` : `${from} -> ${to};`;
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;