@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.
Files changed (154) hide show
  1. package/dist/__golden__/wave-loop/fan-out-applied.json +511 -0
  2. package/dist/__golden__/wave-loop/fan-out-apply-fails.json +406 -0
  3. package/dist/__golden__/wave-loop/fan-out-gated.json +707 -0
  4. package/dist/__golden__/wave-loop/fan-out-no-members.json +898 -0
  5. package/dist/__golden__/wave-loop/fan-out-only.json +514 -0
  6. package/dist/__golden__/wave-loop/fan-out-plan-fails.json +359 -0
  7. package/dist/__golden__/wave-loop/fan-out-same-root.json +311 -0
  8. package/dist/__golden__/wave-loop/op-by-apply-after-fails.json +401 -0
  9. package/dist/__golden__/wave-loop/op-by-apply-hold.json +756 -0
  10. package/dist/__golden__/wave-loop/op-by-apply-ignored.json +340 -0
  11. package/dist/__golden__/wave-loop/op-by-apply-pass.json +400 -0
  12. package/dist/__golden__/wave-loop/op-by-apply-skip.json +404 -0
  13. package/dist/__golden__/wave-loop/op-cost-member-clash.json +62 -0
  14. package/dist/__golden__/wave-loop/op-cost-member.json +191 -0
  15. package/dist/__golden__/wave-loop/op-denied-source.json +407 -0
  16. package/dist/__golden__/wave-loop/op-denied.json +399 -0
  17. package/dist/__golden__/wave-loop/op-discover-rest-short.json +92 -0
  18. package/dist/__golden__/wave-loop/op-discover-rest-stops.json +340 -0
  19. package/dist/__golden__/wave-loop/op-discover-rest.json +659 -0
  20. package/dist/__golden__/wave-loop/op-discover-wide.json +290 -0
  21. package/dist/__golden__/wave-loop/op-held-on-again.json +176 -0
  22. package/dist/__golden__/wave-loop/op-held-on-before-apply.json +689 -0
  23. package/dist/__golden__/wave-loop/op-held-on.json +1455 -0
  24. package/dist/__golden__/wave-loop/op-hooks-apply-fails.json +673 -0
  25. package/dist/__golden__/wave-loop/op-hooks-fail-after-apply.json +737 -0
  26. package/dist/__golden__/wave-loop/op-hooks-fail-before-apply.json +450 -0
  27. package/dist/__golden__/wave-loop/op-hooks-fail-before-plan.json +97 -0
  28. package/dist/__golden__/wave-loop/op-hooks-held.json +1059 -0
  29. package/dist/__golden__/wave-loop/op-hooks-hold-before-apply.json +693 -0
  30. package/dist/__golden__/wave-loop/op-hooks-pass.json +735 -0
  31. package/dist/__golden__/wave-loop/op-hooks-refused-at-base.json +148 -0
  32. package/dist/__golden__/wave-loop/op-hooks-share.json +1225 -0
  33. package/dist/__golden__/wave-loop/op-outcome-held.json +202 -0
  34. package/dist/__golden__/wave-loop/op-outcome-replan-approved.json +286 -0
  35. package/dist/__golden__/wave-loop/op-outcome-replan-forever.json +823 -0
  36. package/dist/__golden__/wave-loop/op-outcome-replan.json +259 -0
  37. package/dist/__golden__/wave-loop/op-outcome-stood-down.json +196 -0
  38. package/dist/__golden__/wave-loop/op-outcome-unreadable.json +193 -0
  39. package/dist/__golden__/wave-loop/op-overridden.json +508 -0
  40. package/dist/__golden__/wave-loop/op-per-run.json +518 -0
  41. package/dist/__golden__/wave-loop/op-point.json +495 -0
  42. package/dist/__golden__/wave-loop/op-pr-review.json +397 -0
  43. package/dist/__golden__/wave-loop/op-sealed.json +125 -0
  44. package/dist/__golden__/wave-loop/op-spend-change-only.json +168 -0
  45. package/dist/__golden__/wave-loop/op-spend.json +328 -0
  46. package/dist/__golden__/wave-loop/op-wave-commands.json +1808 -0
  47. package/dist/__golden__/wave-loop/op-wide-refused.json +291 -0
  48. package/dist/__golden__/wave-loop/op-wide-waiting.json +231 -0
  49. package/dist/__golden__/wave-loop/op-wide.json +440 -0
  50. package/dist/cli/commands/import.d.ts +2 -0
  51. package/dist/cli/commands/import.d.ts.map +1 -1
  52. package/dist/cli/commands/import.js +6 -2
  53. package/dist/cli/handlers/graph.js +24 -1
  54. package/dist/cli/handlers/misc.d.ts.map +1 -1
  55. package/dist/cli/handlers/misc.js +2 -1
  56. package/dist/cli/parse-args.d.ts.map +1 -1
  57. package/dist/cli/parse-args.js +4 -0
  58. package/dist/cli/registry.d.ts +2 -0
  59. package/dist/cli/registry.d.ts.map +1 -1
  60. package/dist/components/fan-out-run.d.ts +4 -3
  61. package/dist/components/fan-out-run.d.ts.map +1 -1
  62. package/dist/components/fan-out-run.js +138 -123
  63. package/dist/graph-detail.d.ts.map +1 -1
  64. package/dist/graph-detail.js +9 -3
  65. package/dist/graph-dot.d.ts.map +1 -1
  66. package/dist/graph-dot.js +36 -2
  67. package/dist/graph-ir.d.ts +98 -3
  68. package/dist/graph-ir.d.ts.map +1 -1
  69. package/dist/graph-ir.js +71 -0
  70. package/dist/graph-lens.js +12 -1
  71. package/dist/graph-mermaid.d.ts +5 -1
  72. package/dist/graph-mermaid.d.ts.map +1 -1
  73. package/dist/graph-mermaid.js +46 -2
  74. package/dist/lexicon.d.ts +22 -1
  75. package/dist/lexicon.d.ts.map +1 -1
  76. package/dist/op/op-waves-run.d.ts +7 -1
  77. package/dist/op/op-waves-run.d.ts.map +1 -1
  78. package/dist/op/op-waves-run.js +129 -72
  79. package/dist/wave-engine.d.ts +74 -5
  80. package/dist/wave-engine.d.ts.map +1 -1
  81. package/dist/wave-engine.js +47 -5
  82. package/dist/wave-runtime.mjs +215 -215
  83. package/package.json +1 -1
  84. package/src/__golden__/wave-loop/fan-out-applied.json +511 -0
  85. package/src/__golden__/wave-loop/fan-out-apply-fails.json +406 -0
  86. package/src/__golden__/wave-loop/fan-out-gated.json +707 -0
  87. package/src/__golden__/wave-loop/fan-out-no-members.json +898 -0
  88. package/src/__golden__/wave-loop/fan-out-only.json +514 -0
  89. package/src/__golden__/wave-loop/fan-out-plan-fails.json +359 -0
  90. package/src/__golden__/wave-loop/fan-out-same-root.json +311 -0
  91. package/src/__golden__/wave-loop/op-by-apply-after-fails.json +401 -0
  92. package/src/__golden__/wave-loop/op-by-apply-hold.json +756 -0
  93. package/src/__golden__/wave-loop/op-by-apply-ignored.json +340 -0
  94. package/src/__golden__/wave-loop/op-by-apply-pass.json +400 -0
  95. package/src/__golden__/wave-loop/op-by-apply-skip.json +404 -0
  96. package/src/__golden__/wave-loop/op-cost-member-clash.json +62 -0
  97. package/src/__golden__/wave-loop/op-cost-member.json +191 -0
  98. package/src/__golden__/wave-loop/op-denied-source.json +407 -0
  99. package/src/__golden__/wave-loop/op-denied.json +399 -0
  100. package/src/__golden__/wave-loop/op-discover-rest-short.json +92 -0
  101. package/src/__golden__/wave-loop/op-discover-rest-stops.json +340 -0
  102. package/src/__golden__/wave-loop/op-discover-rest.json +659 -0
  103. package/src/__golden__/wave-loop/op-discover-wide.json +290 -0
  104. package/src/__golden__/wave-loop/op-held-on-again.json +176 -0
  105. package/src/__golden__/wave-loop/op-held-on-before-apply.json +689 -0
  106. package/src/__golden__/wave-loop/op-held-on.json +1455 -0
  107. package/src/__golden__/wave-loop/op-hooks-apply-fails.json +673 -0
  108. package/src/__golden__/wave-loop/op-hooks-fail-after-apply.json +737 -0
  109. package/src/__golden__/wave-loop/op-hooks-fail-before-apply.json +450 -0
  110. package/src/__golden__/wave-loop/op-hooks-fail-before-plan.json +97 -0
  111. package/src/__golden__/wave-loop/op-hooks-held.json +1059 -0
  112. package/src/__golden__/wave-loop/op-hooks-hold-before-apply.json +693 -0
  113. package/src/__golden__/wave-loop/op-hooks-pass.json +735 -0
  114. package/src/__golden__/wave-loop/op-hooks-refused-at-base.json +148 -0
  115. package/src/__golden__/wave-loop/op-hooks-share.json +1225 -0
  116. package/src/__golden__/wave-loop/op-outcome-held.json +202 -0
  117. package/src/__golden__/wave-loop/op-outcome-replan-approved.json +286 -0
  118. package/src/__golden__/wave-loop/op-outcome-replan-forever.json +823 -0
  119. package/src/__golden__/wave-loop/op-outcome-replan.json +259 -0
  120. package/src/__golden__/wave-loop/op-outcome-stood-down.json +196 -0
  121. package/src/__golden__/wave-loop/op-outcome-unreadable.json +193 -0
  122. package/src/__golden__/wave-loop/op-overridden.json +508 -0
  123. package/src/__golden__/wave-loop/op-per-run.json +518 -0
  124. package/src/__golden__/wave-loop/op-point.json +495 -0
  125. package/src/__golden__/wave-loop/op-pr-review.json +397 -0
  126. package/src/__golden__/wave-loop/op-sealed.json +125 -0
  127. package/src/__golden__/wave-loop/op-spend-change-only.json +168 -0
  128. package/src/__golden__/wave-loop/op-spend.json +328 -0
  129. package/src/__golden__/wave-loop/op-wave-commands.json +1808 -0
  130. package/src/__golden__/wave-loop/op-wide-refused.json +291 -0
  131. package/src/__golden__/wave-loop/op-wide-waiting.json +231 -0
  132. package/src/__golden__/wave-loop/op-wide.json +440 -0
  133. package/src/cli/commands/import-live.test.ts +24 -0
  134. package/src/cli/commands/import.ts +8 -2
  135. package/src/cli/handlers/graph.test.ts +23 -0
  136. package/src/cli/handlers/graph.ts +22 -1
  137. package/src/cli/handlers/misc.ts +2 -1
  138. package/src/cli/main.test.ts +5 -0
  139. package/src/cli/parse-args.ts +3 -0
  140. package/src/cli/registry.ts +2 -0
  141. package/src/codegen/publish-order.test.ts +118 -4
  142. package/src/components/fan-out-run.ts +156 -128
  143. package/src/graph-ci-edges.test.ts +97 -0
  144. package/src/graph-detail.ts +9 -3
  145. package/src/graph-dot.ts +34 -2
  146. package/src/graph-ir.ts +156 -3
  147. package/src/graph-lens.ts +10 -1
  148. package/src/graph-mermaid.ts +44 -2
  149. package/src/lexicon.ts +23 -1
  150. package/src/meta/peer-deps.test.ts +39 -0
  151. package/src/op/op-waves-run.ts +184 -68
  152. package/src/wave-engine.test.ts +38 -1
  153. package/src/wave-engine.ts +101 -5
  154. package/src/wave-loop-golden.test.ts +627 -0
package/src/graph-ir.ts CHANGED
@@ -79,16 +79,90 @@ export interface IRNode {
79
79
  runtimeOwner?: string;
80
80
  }
81
81
 
82
- /** A directed dependency: `from` references an attribute of `to`. */
82
+ /**
83
+ * What an edge means (#3910). `ref` is a typed cross-resource reference (an
84
+ * `AttrRef`, a `Ref` intrinsic, a whole node held in props, or an
85
+ * {@link EntityReference}). The rest are the dependency strings a CI lexicon
86
+ * writes, reported through a plugin's `graphEdges` hook:
87
+ *
88
+ * - `needs` — job waits for job (GitHub/GitLab `needs:`, GitLab stage order)
89
+ * - `calls` — job runs a reusable workflow or a local composite action (`uses:`)
90
+ * - `triggers` — a pipeline starts another one: GitLab `trigger:` (from starts to),
91
+ * GitHub `on.workflow_run` (to finishing starts from)
92
+ * - `includes` — a pipeline pulls in another file's config (GitLab `include:`)
93
+ * - `extends` — a job inherits another job's keys (GitLab `extends:`)
94
+ *
95
+ * Every edge points from the entity that holds the reference to the entity it
96
+ * names. The set grows additively ({@link GRAPH_IR_VERSION}): a reader ignores
97
+ * a kind it does not know.
98
+ */
99
+ export type IREdgeKind = "ref" | "needs" | "calls" | "triggers" | "includes" | "extends";
100
+
101
+ /** A directed dependency: `from` references `to` (an attribute of it, for `ref`). */
83
102
  export interface IREdge {
84
103
  from: string;
85
104
  to: string;
86
- /** "ref" for an AttrRef-derived edge. */
87
- kind: "ref";
105
+ /** "ref" for an AttrRef-derived edge; a CI kind otherwise (see {@link IREdgeKind}). */
106
+ kind: IREdgeKind;
88
107
  /** The consumer-side property the reference flows through, when derivable. */
89
108
  viaAttr?: string;
90
109
  /** The producer-side attribute referenced (e.g. "id"). Added at detail T3. */
91
110
  toAttr?: string;
111
+ /**
112
+ * True when the edge leaves the pipeline `from` belongs to (#3910): a
113
+ * reusable-workflow call, a `workflow_run` trigger, a GitLab child or
114
+ * multi-project trigger, an include, a cross-project `needs`. A renderer
115
+ * that draws each pipeline as a group ({@link IRGroups.byPipeline}) reads
116
+ * this to route the edge between groups. Absent on every in-pipeline edge.
117
+ */
118
+ crossPipeline?: boolean;
119
+ }
120
+
121
+ /**
122
+ * A reference whose target is not a node of this graph (#3910): a job
123
+ * `needs` a job no workflow declares, a `uses:` names another repository's
124
+ * workflow, a GitLab `include:` names a file chant did not generate. Kept on
125
+ * the IR rather than dropped or matched to a near name, so a reader can tell
126
+ * "no dependency" from "a dependency on something outside this project".
127
+ * Field names follow the live resolver's `DanglingRef` (./graph-refs.ts).
128
+ */
129
+ export interface IRDanglingRef {
130
+ /** The node holding the reference. */
131
+ from: string;
132
+ /** The edge kind the reference would have been. */
133
+ kind: IREdgeKind;
134
+ /** Where on `from` it was written, e.g. `needs`, `uses`, `trigger.include`. */
135
+ path: string;
136
+ /** The reference exactly as written. */
137
+ value: string;
138
+ /** Same meaning as {@link IREdge.crossPipeline}. */
139
+ crossPipeline?: boolean;
140
+ }
141
+
142
+ /**
143
+ * One reference a lexicon reports through its `graphEdges` hook (#3910),
144
+ * resolved by the lexicon from strings core cannot read: job names, workflow
145
+ * file paths, pipeline names. `from` and `to` are entity keys. A reference
146
+ * the lexicon could not resolve sets `dangling` and carries the string as
147
+ * written in `to`; core turns it into an {@link IRDanglingRef}.
148
+ */
149
+ export interface LexiconGraphReference {
150
+ from: string;
151
+ to: string;
152
+ kind: IREdgeKind;
153
+ viaAttr?: string;
154
+ crossPipeline?: boolean;
155
+ dangling?: boolean;
156
+ }
157
+
158
+ /** What a lexicon's `graphEdges` hook returns (#3910). */
159
+ export interface LexiconGraphEdges {
160
+ references: LexiconGraphReference[];
161
+ /**
162
+ * Pipeline membership, `pipeline name -> entity keys`: a workflow and its
163
+ * jobs, a GitLab pipeline and its jobs. Lands on {@link IRGroups.byPipeline}.
164
+ */
165
+ pipelines?: Record<string, string[]>;
92
166
  }
93
167
 
94
168
  /**
@@ -210,6 +284,11 @@ export interface IRGroups {
210
284
  * wave-laned renderer reads this the way `byStack` drives boundary boxes.
211
285
  * Present only for the component-DAG projection; absent for entity IR. */
212
286
  byWave?: Record<string, string[]>;
287
+ /** CI pipeline membership (#3910): a workflow or pipeline name -> the node
288
+ * ids it runs (the workflow node and its jobs). Reported by a CI lexicon's
289
+ * `graphEdges` hook; absent when no lexicon reports one. Edges marked
290
+ * `crossPipeline` run between these groups. */
291
+ byPipeline?: Record<string, string[]>;
213
292
  }
214
293
 
215
294
  /** A cross-stack export this stack publishes (a `stackOutput`/`output`): its
@@ -335,6 +414,11 @@ export interface GraphIR {
335
414
  imports?: IRImport[];
336
415
  /** The CI/pipeline projection alongside the component graph (#989) — see {@link IRPipeline}. */
337
416
  pipeline?: IRPipeline;
417
+ /**
418
+ * References that name nothing in this graph (#3910) — see
419
+ * {@link IRDanglingRef}. Absent when there are none.
420
+ */
421
+ dangling?: IRDanglingRef[];
338
422
  /**
339
423
  * Attributes chant computed rather than read back from the provider, keyed by the kind
340
424
  * they were folded onto. An enrichment pass records what it derived here so callers can
@@ -685,6 +769,75 @@ export function buildGraphIr(
685
769
  return ir;
686
770
  }
687
771
 
772
+ /**
773
+ * Add what the lexicons' `graphEdges` hooks reported (#3910) to a built IR.
774
+ *
775
+ * A resolved reference between two nodes becomes an edge. When it lands on
776
+ * the same `from`/`to`/`viaAttr` as an edge the AttrRef walk already found
777
+ * (a GitLab `needs:` holding the Job object itself, say), the lexicon's edge
778
+ * replaces it, since the lexicon knows the reference is a `needs` and the walk
779
+ * only knows it is a reference. A reference marked `dangling`, or whose
780
+ * target is not a node, goes on `ir.dangling` as written. One whose `from` is
781
+ * not a node is dropped: there is nothing to hang it on.
782
+ *
783
+ * Pure. Returns `ir` itself when no report added anything, so a project with
784
+ * no CI lexicon prints exactly what it did before.
785
+ */
786
+ export function applyLexiconGraphEdges(ir: GraphIR, reports: readonly LexiconGraphEdges[]): GraphIR {
787
+ const nodeIds = new Set(ir.nodes.map((n) => n.id));
788
+ const edgeMap = new Map<string, IREdge>(ir.edges.map((e) => [edgeKey(e), e]));
789
+ const danglingMap = new Map<string, IRDanglingRef>((ir.dangling ?? []).map((d) => [danglingKey(d), d]));
790
+ const byPipeline: Record<string, string[]> = { ...(ir.groups.byPipeline ?? {}) };
791
+ let changed = false;
792
+
793
+ for (const report of reports) {
794
+ for (const ref of report.references) {
795
+ if (!nodeIds.has(ref.from)) continue;
796
+ if (ref.dangling || !nodeIds.has(ref.to)) {
797
+ const d: IRDanglingRef = {
798
+ from: ref.from,
799
+ kind: ref.kind,
800
+ path: ref.viaAttr ?? ref.kind,
801
+ value: ref.to,
802
+ ...(ref.crossPipeline ? { crossPipeline: true } : {}),
803
+ };
804
+ danglingMap.set(danglingKey(d), d);
805
+ changed = true;
806
+ continue;
807
+ }
808
+ if (ref.to === ref.from) continue;
809
+ const e: IREdge = {
810
+ from: ref.from,
811
+ to: ref.to,
812
+ kind: ref.kind,
813
+ ...(ref.viaAttr ? { viaAttr: ref.viaAttr } : {}),
814
+ ...(ref.crossPipeline ? { crossPipeline: true } : {}),
815
+ };
816
+ edgeMap.set(edgeKey(e), e);
817
+ changed = true;
818
+ }
819
+ for (const [name, members] of Object.entries(report.pipelines ?? {})) {
820
+ const present = members.filter((m) => nodeIds.has(m));
821
+ if (present.length === 0) continue;
822
+ byPipeline[name] = [...new Set([...(byPipeline[name] ?? []), ...present])].sort();
823
+ changed = true;
824
+ }
825
+ }
826
+ if (!changed) return ir;
827
+
828
+ const edges = [...edgeMap.values()].sort((a, b) => edgeKey(a).localeCompare(edgeKey(b)));
829
+ const dangling = [...danglingMap.values()].sort((a, b) => danglingKey(a).localeCompare(danglingKey(b)));
830
+ const groups: IRGroups = { ...ir.groups };
831
+ if (Object.keys(byPipeline).length) groups.byPipeline = sortKeys(byPipeline);
832
+ const out: GraphIR = { ...ir, edges, groups };
833
+ if (dangling.length) out.dangling = dangling;
834
+ return out;
835
+ }
836
+
837
+ function danglingKey(d: IRDanglingRef): string {
838
+ return `${d.from}\0${d.path}\0${d.value}`;
839
+ }
840
+
688
841
  function sortKeys(rec: Record<string, string[]>): Record<string, string[]> {
689
842
  const out: Record<string, string[]> = {};
690
843
  for (const k of Object.keys(rec).sort()) out[k] = rec[k];
package/src/graph-lens.ts CHANGED
@@ -116,8 +116,17 @@ function subgraph(ir: GraphIR, keep: Set<string>): GraphIR {
116
116
  const groups: GraphIR["groups"] = {};
117
117
  if (Object.keys(byLexicon).length) groups.byLexicon = sortGroups(byLexicon);
118
118
  if (Object.keys(byComposite).length) groups.byComposite = sortGroups(byComposite);
119
+ if (ir.groups.byPipeline) {
120
+ const byPipeline: Record<string, string[]> = {};
121
+ for (const [name, members] of Object.entries(ir.groups.byPipeline)) {
122
+ const kept = members.filter((m) => keep.has(m));
123
+ if (kept.length) byPipeline[name] = kept;
124
+ }
125
+ if (Object.keys(byPipeline).length) groups.byPipeline = sortGroups(byPipeline);
126
+ }
127
+ const dangling = ir.dangling?.filter((d) => keep.has(d.from));
119
128
 
120
- return { nodes, edges, groups };
129
+ return { nodes, edges, groups, ...(dangling && dangling.length ? { dangling } : {}) };
121
130
  }
122
131
 
123
132
  function sortGroups(rec: Record<string, string[]>): Record<string, string[]> {
@@ -25,16 +25,35 @@ export function toMermaid(ir: GraphIR): string {
25
25
  // sorted, so output is deterministic.
26
26
  const clusters = ir.groups.byWave ?? ir.groups.byLexicon;
27
27
  const clusterPrefix = ir.groups.byWave ? "wave" : "lex";
28
+ // CI pipelines (#3910) nest inside their lexicon's cluster, one subgraph per
29
+ // workflow or pipeline, so a cross-pipeline edge visibly leaves its box.
30
+ const pipelineOf = new Map<string, string>();
31
+ for (const [name, members] of Object.entries(ir.groups.byPipeline ?? {})) {
32
+ for (const id of members) if (!pipelineOf.has(id)) pipelineOf.set(id, name);
33
+ }
28
34
  const grouped = new Set<string>();
29
35
  if (clusters) {
30
36
  for (const [name, members] of Object.entries(clusters)) {
31
37
  lines.push(` subgraph ${safeId(`${clusterPrefix}_${name}`, ids)}[${quote(name)}]`);
38
+ const nested = new Map<string, IRNode[]>();
32
39
  for (const id of members) {
33
40
  const node = ir.nodes.find((n) => n.id === id);
34
41
  if (!node) continue;
35
42
  grouped.add(id);
43
+ const pipeline = pipelineOf.get(id);
44
+ if (pipeline !== undefined) {
45
+ const arr = nested.get(pipeline) ?? [];
46
+ arr.push(node);
47
+ nested.set(pipeline, arr);
48
+ continue;
49
+ }
36
50
  lines.push(` ${nodeLine(node, ids)}`);
37
51
  }
52
+ for (const [pipeline, nodes] of [...nested].sort(([a], [b]) => a.localeCompare(b))) {
53
+ lines.push(` subgraph ${safeId(`pipe_${name}_${pipeline}`, ids)}[${quote(pipeline)}]`);
54
+ for (const node of nodes) lines.push(` ${nodeLine(node, ids)}`);
55
+ lines.push(" end");
56
+ }
38
57
  lines.push(" end");
39
58
  }
40
59
  }
@@ -47,6 +66,19 @@ export function toMermaid(ir: GraphIR): string {
47
66
  lines.push(` ${edgeLine(e, ids)}`);
48
67
  }
49
68
 
69
+ // A reference to nothing in the graph (#3910) is drawn, not dropped: a
70
+ // dashed edge to a rounded stub named by the string as written.
71
+ const stubs = new Map<string, string>();
72
+ for (const d of ir.dangling ?? []) {
73
+ let stub = stubs.get(d.value);
74
+ if (!stub) {
75
+ stub = safeId(`dangling_${d.value}`, ids);
76
+ stubs.set(d.value, stub);
77
+ lines.push(` ${stub}([${quote(`${d.value}\n(not declared)`)}])`);
78
+ }
79
+ lines.push(` ${safeId(d.from, ids)} -.->|${quote(d.kind)}| ${stub}`);
80
+ }
81
+
50
82
  return lines.join("\n") + "\n";
51
83
  }
52
84
 
@@ -60,8 +92,18 @@ function nodeLine(node: IRNode, ids: Map<string, string>): string {
60
92
  function edgeLine(e: IREdge, ids: Map<string, string>): string {
61
93
  const from = safeId(e.from, ids);
62
94
  const to = safeId(e.to, ids);
63
- const label = [e.viaAttr, e.toAttr].filter(Boolean).join(" → ");
64
- return label ? `${from} -->|${quote(label)}| ${to}` : `${from} --> ${to}`;
95
+ const label = edgeLabel(e);
96
+ // A cross-pipeline edge is dashed, so it reads as leaving its workflow.
97
+ const arrow = e.crossPipeline ? "-.->" : "-->";
98
+ return label ? `${from} ${arrow}|${quote(label)}| ${to}` : `${from} ${arrow} ${to}`;
99
+ }
100
+
101
+ /** A typed reference is labelled by the attribute it flows through; a CI edge
102
+ * (#3910) by its kind, plus the attribute when that says something more
103
+ * (`needs (stage)` for GitLab's stage order). */
104
+ export function edgeLabel(e: IREdge): string {
105
+ if (e.kind === "ref" || !e.kind) return [e.viaAttr, e.toAttr].filter(Boolean).join(" → ");
106
+ return e.viaAttr && e.viaAttr !== e.kind ? `${e.kind} (${e.viaAttr})` : e.kind;
65
107
  }
66
108
 
67
109
  /** Map an arbitrary logical name to a stable, unique Mermaid-safe node id. */
package/src/lexicon.ts CHANGED
@@ -23,7 +23,7 @@ import type { OwnershipChannel, OwnershipMarker } from "./ownership";
23
23
  import type { LexiconConfigSchema } from "./lexicon-config";
24
24
  import type { RuleMeta } from "./audit/catalog";
25
25
  import type { ReferenceCatalog } from "./graph-refs";
26
- import type { IREdge } from "./graph-ir";
26
+ import type { IREdge, LexiconGraphEdges } from "./graph-ir";
27
27
  import type { DescribeResourcesResult, UnobservedReason } from "./observation";
28
28
  import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity";
29
29
  import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
@@ -1433,6 +1433,23 @@ export interface LexiconPlugin {
1433
1433
  */
1434
1434
  graphMeta?(entities: ReadonlyMap<string, Declarable>): Record<string, unknown> | undefined;
1435
1435
 
1436
+ /**
1437
+ * Edges this lexicon's entities declare as strings (#3910). A typed lexicon
1438
+ * gets its edges from `AttrRef`s for free; a CI lexicon writes its
1439
+ * dependencies as job names (`needs: ["build"]`), workflow paths (`uses:
1440
+ * ./.github/workflows/ci.yml`) and file names (`include:`), which only the
1441
+ * lexicon can resolve. It resolves them here, against the whole entity map,
1442
+ * and reports each as a `{ from, to, kind }` between entity keys, or as
1443
+ * `dangling` with the string as written when nothing declared matches.
1444
+ * `pipelines` says which entities run in which workflow or pipeline.
1445
+ *
1446
+ * Called by `chant graph` with the discovered entities, after the plugins
1447
+ * are loaded; merged by `applyLexiconGraphEdges` (./graph-ir.ts). Pure: no
1448
+ * I/O. Return `undefined` when the project declares nothing this lexicon
1449
+ * reads. Omit for lexicons whose references are typed.
1450
+ */
1451
+ graphEdges?(entities: ReadonlyMap<string, Declarable>): LexiconGraphEdges | undefined;
1452
+
1436
1453
  // LSP
1437
1454
  /** Provide completions for LSP */
1438
1455
  completionProvider?(ctx: CompletionContext): CompletionItem[];
@@ -2051,6 +2068,10 @@ export interface LexiconPlugin {
2051
2068
  * (the form a user would have authored). `verbatim: true` keeps them. Targets
2052
2069
  * whose live config is already the declared shape (e.g. a CloudFormation
2053
2070
  * original template) may ignore it.
2071
+ *
2072
+ * `skipUndeclarable` (`--skip-undeclarable`): an implementation that fails
2073
+ * an import for live objects no declaration can hold, naming them, imports
2074
+ * the rest instead and returns them as warnings.
2054
2075
  */
2055
2076
  exportResources?(options: {
2056
2077
  environment: string;
@@ -2064,6 +2085,7 @@ export interface LexiconPlugin {
2064
2085
  selector?: ResourceSelector;
2065
2086
  owned?: boolean;
2066
2087
  verbatim?: boolean;
2088
+ skipUndeclarable?: boolean;
2067
2089
  }): Promise<ExportedTemplate>;
2068
2090
  }
2069
2091
 
@@ -124,3 +124,42 @@ describe("the provider-aware rule, over fixtures", () => {
124
124
  expect(rangeViolations(skewed, { aws: { typescript: ">=5.0.0" } })).toEqual([]);
125
125
  });
126
126
  });
127
+
128
+ /**
129
+ * Same rule over every dependency field of every publishable package (#3905).
130
+ * docker, fly, k8s, grafana and helm once depended on sibling lexicons at `*`,
131
+ * which follows `latest` instead of the release they shipped in. Both release
132
+ * recipes now write `^<provider version>`; this fails if any `@intentius/*`
133
+ * range in dependencies, peerDependencies or optionalDependencies is `*` or
134
+ * anything other than the provider's current version.
135
+ */
136
+ describe("every in-family @intentius/* range pins its provider's current version", () => {
137
+ type Manifest = {
138
+ name: string;
139
+ version: string;
140
+ private?: boolean;
141
+ dependencies?: Record<string, string>;
142
+ peerDependencies?: Record<string, string>;
143
+ optionalDependencies?: Record<string, string>;
144
+ };
145
+ const dirs = [
146
+ ...readdirSync(join(repoRoot, "packages")).map((n) => join("packages", n)),
147
+ ...lexicons.map((n) => join("lexicons", n)),
148
+ ].filter((d) => existsSync(join(repoRoot, d, "package.json")));
149
+ const manifests = dirs.map((d) => JSON.parse(readFileSync(join(repoRoot, d, "package.json"), "utf-8")) as Manifest);
150
+ const publishable = manifests.filter((m) => !m.private);
151
+ const versions = Object.fromEntries(publishable.map((m) => [m.name, m.version]));
152
+
153
+ test.each(publishable.map((m) => m.name))("%s", (name) => {
154
+ const m = publishable.find((p) => p.name === name)!;
155
+ const bad: string[] = [];
156
+ for (const field of ["dependencies", "peerDependencies", "optionalDependencies"] as const) {
157
+ for (const [dep, range] of Object.entries(m[field] ?? {})) {
158
+ if (!dep.startsWith("@intentius/")) continue;
159
+ const want = versions[dep] ? `^${versions[dep]}` : undefined;
160
+ if (range !== want) bad.push(`${field}.${dep} is "${range}", want ${want ?? "a published workspace package"}`);
161
+ }
162
+ }
163
+ expect(bad).toEqual([]);
164
+ });
165
+ });