@intentius/chant 0.124.22 → 0.124.24

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 (187) hide show
  1. package/dist/audit/rules-doc.js +1 -1
  2. package/dist/ci-jobs.d.ts +41 -1
  3. package/dist/ci-jobs.d.ts.map +1 -1
  4. package/dist/ci-jobs.js +48 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/build.js +14 -0
  7. package/dist/cli/handlers/approve.d.ts.map +1 -1
  8. package/dist/cli/handlers/approve.js +9 -0
  9. package/dist/cli/handlers/emulator.d.ts +13 -2
  10. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  11. package/dist/cli/handlers/emulator.js +63 -3
  12. package/dist/cli/handlers/graph.js +24 -1
  13. package/dist/cli/handlers/promote.d.ts.map +1 -1
  14. package/dist/cli/handlers/promote.js +7 -1
  15. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  16. package/dist/cli/handlers/run-wave.js +5 -4
  17. package/dist/cli/handlers/run.d.ts +4 -3
  18. package/dist/cli/handlers/run.d.ts.map +1 -1
  19. package/dist/cli/handlers/run.js +73 -9
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +17 -4
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +31 -0
  24. package/dist/cli/registry.d.ts +12 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/cli-support.d.ts +45 -6
  27. package/dist/components/cli-support.d.ts.map +1 -1
  28. package/dist/components/cli-support.js +97 -22
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +25 -10
  31. package/dist/graph-detail.d.ts.map +1 -1
  32. package/dist/graph-detail.js +24 -2
  33. package/dist/graph-ir.d.ts +43 -0
  34. package/dist/graph-ir.d.ts.map +1 -1
  35. package/dist/graph-ir.js +27 -0
  36. package/dist/lexicon.d.ts +25 -1
  37. package/dist/lexicon.d.ts.map +1 -1
  38. package/dist/lifecycle/git.d.ts +11 -0
  39. package/dist/lifecycle/git.d.ts.map +1 -1
  40. package/dist/lifecycle/git.js +62 -6
  41. package/dist/lifecycle/no-record.d.ts +59 -0
  42. package/dist/lifecycle/no-record.d.ts.map +1 -0
  43. package/dist/lifecycle/no-record.js +88 -0
  44. package/dist/op/activities/shell.d.ts.map +1 -1
  45. package/dist/op/activities/shell.js +30 -9
  46. package/dist/op/emulator-lifecycle.d.ts +23 -0
  47. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  48. package/dist/op/emulator-lifecycle.js +39 -2
  49. package/dist/op/gate.d.ts.map +1 -1
  50. package/dist/op/gate.js +3 -0
  51. package/dist/op/index.d.ts +1 -1
  52. package/dist/op/index.d.ts.map +1 -1
  53. package/dist/op/index.js +1 -1
  54. package/dist/op/local-executor.d.ts +10 -0
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/local-executor.js +7 -2
  57. package/dist/op/op-waves-notify.d.ts +2 -0
  58. package/dist/op/op-waves-notify.d.ts.map +1 -1
  59. package/dist/op/op-waves-notify.js +9 -3
  60. package/dist/op/op-waves-run.d.ts +8 -2
  61. package/dist/op/op-waves-run.d.ts.map +1 -1
  62. package/dist/op/op-waves-run.js +36 -11
  63. package/dist/op/op-waves.d.ts +85 -1
  64. package/dist/op/op-waves.d.ts.map +1 -1
  65. package/dist/op/op-waves.js +153 -1
  66. package/dist/op/runtimes/local.d.ts.map +1 -1
  67. package/dist/op/runtimes/local.js +6 -0
  68. package/dist/op/step-console.d.ts +65 -0
  69. package/dist/op/step-console.d.ts.map +1 -0
  70. package/dist/op/step-console.js +140 -0
  71. package/dist/point-decision.d.ts.map +1 -1
  72. package/dist/point-decision.js +3 -0
  73. package/dist/wave-runtime.mjs +240 -224
  74. package/dist/workspace/checks/links.d.ts +5 -0
  75. package/dist/workspace/checks/links.d.ts.map +1 -1
  76. package/dist/workspace/checks/links.js +54 -8
  77. package/dist/workspace/checks.d.ts +4 -1
  78. package/dist/workspace/checks.d.ts.map +1 -1
  79. package/dist/workspace/checks.js +6 -2
  80. package/dist/workspace/compose-graph.d.ts +26 -4
  81. package/dist/workspace/compose-graph.d.ts.map +1 -1
  82. package/dist/workspace/compose-graph.js +21 -7
  83. package/dist/workspace/dbt-project-reader.d.ts +36 -0
  84. package/dist/workspace/dbt-project-reader.d.ts.map +1 -0
  85. package/dist/workspace/dbt-project-reader.js +290 -0
  86. package/dist/workspace/graph-cli.d.ts.map +1 -1
  87. package/dist/workspace/graph-cli.js +3 -1
  88. package/dist/workspace/graph.schema.json +109 -4
  89. package/dist/workspace/intent.js +7 -2
  90. package/dist/workspace/kind-link-readers.d.ts +84 -0
  91. package/dist/workspace/kind-link-readers.d.ts.map +1 -0
  92. package/dist/workspace/kind-link-readers.js +53 -0
  93. package/dist/workspace/kinds.d.ts +15 -0
  94. package/dist/workspace/kinds.d.ts.map +1 -1
  95. package/dist/workspace/kinds.js +11 -0
  96. package/dist/workspace/lexicon-outputs.d.ts +63 -0
  97. package/dist/workspace/lexicon-outputs.d.ts.map +1 -0
  98. package/dist/workspace/lexicon-outputs.js +241 -0
  99. package/dist/workspace/links.d.ts +29 -7
  100. package/dist/workspace/links.d.ts.map +1 -1
  101. package/dist/workspace/links.js +109 -34
  102. package/dist/workspace/member-handles.d.ts +18 -1
  103. package/dist/workspace/member-handles.d.ts.map +1 -1
  104. package/dist/workspace/member-handles.js +31 -14
  105. package/dist/workspace/nested-graph.d.ts +1 -1
  106. package/dist/workspace/nested-graph.d.ts.map +1 -1
  107. package/dist/workspace/nested-graph.js +5 -1
  108. package/dist/workspace/records-checkout.d.ts +11 -1
  109. package/dist/workspace/records-checkout.d.ts.map +1 -1
  110. package/dist/workspace/records-checkout.js +38 -2
  111. package/dist/workspace/runs.d.ts.map +1 -1
  112. package/dist/workspace/runs.js +4 -2
  113. package/dist/workspace/status.d.ts.map +1 -1
  114. package/dist/workspace/status.js +3 -2
  115. package/dist/workspace/workspace-kinds.schema.json +5 -0
  116. package/package.json +1 -1
  117. package/src/audit/rules-doc.ts +1 -1
  118. package/src/ci-jobs.ts +58 -1
  119. package/src/cli/commands/build.ts +18 -0
  120. package/src/cli/handlers/approve.ts +9 -0
  121. package/src/cli/handlers/emulator.test.ts +61 -1
  122. package/src/cli/handlers/emulator.ts +65 -4
  123. package/src/cli/handlers/graph.ts +22 -1
  124. package/src/cli/handlers/promote.test.ts +7 -0
  125. package/src/cli/handlers/promote.ts +7 -1
  126. package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
  127. package/src/cli/handlers/run-wave.ts +5 -4
  128. package/src/cli/handlers/run.test.ts +126 -0
  129. package/src/cli/handlers/run.ts +69 -9
  130. package/src/cli/main.test.ts +24 -0
  131. package/src/cli/main.ts +17 -4
  132. package/src/cli/parse-args.ts +21 -0
  133. package/src/cli/registry.ts +12 -0
  134. package/src/components/cli-support.test.ts +75 -0
  135. package/src/components/cli-support.ts +130 -25
  136. package/src/components/driver.test.ts +28 -0
  137. package/src/components/driver.ts +23 -10
  138. package/src/components/generate-disabled.test.ts +56 -0
  139. package/src/graph-ci-edges.test.ts +50 -0
  140. package/src/graph-detail.ts +21 -2
  141. package/src/graph-ir.test.ts +23 -1
  142. package/src/graph-ir.ts +65 -0
  143. package/src/lexicon.ts +26 -1
  144. package/src/lifecycle/git.ts +61 -6
  145. package/src/lifecycle/no-record.test.ts +175 -0
  146. package/src/lifecycle/no-record.ts +108 -0
  147. package/src/op/activities/shell.ts +28 -6
  148. package/src/op/emulator-lifecycle.test.ts +24 -1
  149. package/src/op/emulator-lifecycle.ts +58 -2
  150. package/src/op/gate.ts +3 -0
  151. package/src/op/index.ts +1 -1
  152. package/src/op/local-executor.ts +20 -3
  153. package/src/op/op-waves-jobs.test.ts +213 -0
  154. package/src/op/op-waves-notify.ts +11 -5
  155. package/src/op/op-waves-run.ts +42 -11
  156. package/src/op/op-waves.ts +202 -2
  157. package/src/op/runtimes/local.ts +6 -0
  158. package/src/op/step-console.test.ts +150 -0
  159. package/src/op/step-console.ts +155 -0
  160. package/src/point-decision-bundle.test.ts +1 -0
  161. package/src/point-decision.ts +3 -0
  162. package/src/wave-loop-golden.test.ts +3 -0
  163. package/src/workspace/checks/links.ts +57 -8
  164. package/src/workspace/checks.e2e.test.ts +1 -1
  165. package/src/workspace/checks.ts +9 -2
  166. package/src/workspace/compose-graph.test.ts +55 -0
  167. package/src/workspace/compose-graph.ts +33 -9
  168. package/src/workspace/dbt-kind.e2e.test.ts +95 -0
  169. package/src/workspace/dbt-kind.test.ts +283 -0
  170. package/src/workspace/dbt-project-reader.ts +291 -0
  171. package/src/workspace/graph-cli.ts +3 -1
  172. package/src/workspace/graph-contract.test.ts +26 -1
  173. package/src/workspace/graph.schema.json +109 -4
  174. package/src/workspace/intent.ts +7 -2
  175. package/src/workspace/kind-link-readers.ts +109 -0
  176. package/src/workspace/kinds.ts +21 -1
  177. package/src/workspace/lexicon-outputs.ts +272 -0
  178. package/src/workspace/links.test.ts +22 -0
  179. package/src/workspace/links.ts +129 -34
  180. package/src/workspace/member-handles.ts +28 -8
  181. package/src/workspace/nested-graph.test.ts +14 -0
  182. package/src/workspace/nested-graph.ts +7 -2
  183. package/src/workspace/records-checkout.ts +36 -2
  184. package/src/workspace/records-uncommitted.test.ts +5 -1
  185. package/src/workspace/runs.ts +4 -2
  186. package/src/workspace/status.ts +3 -2
  187. package/src/workspace/workspace-kinds.schema.json +5 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The `dbt` member kind (#3932): the sql lexicon's kinds file, the
3
+ * `dbt-project` link reader, and `chant workspace check` over the links it
4
+ * finds. The producer here is an `other` member whose entry lists the
5
+ * outputs a sql member exposes (#3931), so the reader's names are checked
6
+ * against the naming contract without a sql project.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { afterAll, describe, expect, test } from "vitest";
12
+ import { cleanScratch, contract, declaration, REPO, repo } from "./__fixtures__/contract-repo";
13
+ import { runDeclarationChecks } from "./checks";
14
+ import { parseDeclaration } from "./declaration";
15
+ import { readDbtProject } from "./dbt-project-reader";
16
+ import { readKindLinks } from "./kind-link-readers";
17
+ import { createKindRegistry, parseKindData } from "./kinds";
18
+ import { graphLinks, resolveLinks, type LinkTableRow, type MemberHandles } from "./links";
19
+ import { workingTree } from "./tree";
20
+ import graphSchema from "./graph.schema.json";
21
+
22
+ afterAll(cleanScratch);
23
+
24
+ const SQL_KINDS = readFileSync(join(REPO, "lexicons", "sql", "workspace-kinds.json"), "utf-8");
25
+ const SQL_PACKAGE = JSON.stringify({ name: "@intentius/chant-lexicon-sql", version: "0.0.0", exports: { "./workspace-kinds": "./workspace-kinds.json" } });
26
+
27
+ const ORDERS = ["public.orders", "public.orders.id", "public.orders.customer_id", "public.orders.status"];
28
+
29
+ const SOURCES = `version: 2
30
+
31
+ sources:
32
+ - name: shop
33
+ schema: public
34
+ tables:
35
+ - name: orders
36
+ columns:
37
+ - name: id
38
+ - name: customer_id
39
+ description: who ordered
40
+ `;
41
+
42
+ interface Fixture {
43
+ outputs?: string[];
44
+ dbtFields?: Record<string, unknown>;
45
+ members?: unknown[];
46
+ files?: Record<string, string>;
47
+ }
48
+
49
+ /** A workspace with a `db` producer, a `analytics` dbt project and the sql lexicon's kinds pinned by path. */
50
+ function workspace(opts: Fixture = {}): string {
51
+ return repo({
52
+ "chant.workspace.json": declaration(
53
+ [
54
+ { name: "db", dir: "db", kind: "other", because: "stands in for a sql member (#3931)", suppress: [{ check: "WSP009", because: "a stand-in" }], outputs: opts.outputs ?? ORDERS },
55
+ { name: "analytics", dir: "analytics", kind: "dbt", ...(opts.dbtFields === undefined ? { fields: { sqlMember: "db" } } : Object.keys(opts.dbtFields).length ? { fields: opts.dbtFields } : {}) },
56
+ ...(opts.members ?? []),
57
+ ],
58
+ { pins: [{ path: "plugins/sql" }] },
59
+ ),
60
+ "plugins/sql/package.json": SQL_PACKAGE,
61
+ "plugins/sql/workspace-kinds.json": SQL_KINDS,
62
+ "db/README": "",
63
+ "analytics/dbt_project.yml": "name: analytics\nversion: '1.0'\nprofile: analytics\n",
64
+ "analytics/models/staging/sources.yml": SOURCES,
65
+ ...(opts.files ?? {}),
66
+ });
67
+ }
68
+
69
+ const rows = (links: LinkTableRow[]) =>
70
+ links
71
+ .filter((r) => r.origin === "kind-reader")
72
+ .map((r) => (r.status === "ambiguous" ? `${r.consumer} -> ? ${r.input} ambiguous ${r.candidates.map((c) => c.producer).join("|")}` : `${r.consumer} -> ${r.producer}.${r.output} ${r.status} ${r.file}:${r.line}`));
73
+
74
+ describe("the dbt kind in the sql lexicon's kinds file (#3932)", () => {
75
+ test("validates, probes dbt_project.yml and names the dbt-project reader", () => {
76
+ const { kinds, problems } = parseKindData(SQL_KINDS, "@intentius/chant-lexicon-sql");
77
+ expect(problems).toEqual([]);
78
+ expect(kinds.map((k) => [k.name, k.reader, k.probe])).toEqual([["dbt", "dbt-project", { anyFile: ["dbt_project.yml"] }]]);
79
+ expect(Object.keys(kinds[0].fields ?? {})).toEqual(["sqlMember", "dialect"]);
80
+ });
81
+
82
+ test("a reader this chant doesn't have keeps the kind without one, and says so", () => {
83
+ const text = JSON.stringify({ schema: 1, kinds: [{ name: "lake", description: "a lake", precedence: 10, probe: { anyFile: ["lake.yml"] }, reader: "lake-tables" }] });
84
+ const { kinds, problems } = parseKindData(text, "pkg");
85
+ expect(kinds.map((k) => [k.name, k.reader])).toEqual([["lake", undefined]]);
86
+ expect(problems).toEqual([expect.stringMatching(/kind lake names link reader lake-tables, which this chant does not have/)]);
87
+ });
88
+ });
89
+
90
+ describe("dbt sources as links, in chant workspace check (#3932)", () => {
91
+ test("each source table and column links to the sql member's <schema>.<table>[.<column>] output", async () => {
92
+ const report = await runDeclarationChecks(workspace());
93
+ expect(report.diagnostics).toEqual([]);
94
+ expect(report.ok).toBe(true);
95
+ expect(rows(report.links)).toEqual([
96
+ "analytics -> db.public.orders resolved analytics/models/staging/sources.yml:7",
97
+ "analytics -> db.public.orders.id resolved analytics/models/staging/sources.yml:9",
98
+ "analytics -> db.public.orders.customer_id resolved analytics/models/staging/sources.yml:10",
99
+ ]);
100
+ expect(report.links[0]).toMatchObject({ origin: "kind-reader", reader: "dbt-project", kind: "output", resolves: "source" });
101
+ });
102
+
103
+ test("acceptance: dropping a column a source reads fails check and names the dbt source file and line", async () => {
104
+ const report = await runDeclarationChecks(workspace({ outputs: ["public.orders", "public.orders.id", "public.orders.status"] }));
105
+ expect(report.ok).toBe(false);
106
+ expect(report.diagnostics.map((d) => `${d.ruleId}:${d.severity}:${d.entity}:${d.file}:${d.line}`)).toEqual([
107
+ "WSP093:error:analytics:analytics/models/staging/sources.yml:10",
108
+ ]);
109
+ expect(report.diagnostics[0].message).toMatch(
110
+ /^analytics's link to db output public\.orders\.customer_id \(read by dbt-project from analytics\/models\/staging\/sources\.yml:10\) does not resolve: db has no output public\.orders\.customer_id/,
111
+ );
112
+ });
113
+
114
+ test("dropping the table fails the table and each column the source names", async () => {
115
+ const report = await runDeclarationChecks(workspace({ outputs: ["public.customers"] }));
116
+ expect(report.diagnostics.map((d) => `${d.ruleId}:${d.line}`)).toEqual(["WSP093:7", "WSP093:9", "WSP093:10"]);
117
+ });
118
+
119
+ test("a rename shows the old output missing with the near name", async () => {
120
+ const report = await runDeclarationChecks(workspace({ outputs: ["public.orders", "public.orders.id", "public.orders.customerId"] }));
121
+ expect(report.diagnostics[0].message).toMatch(/did you mean public\.orders\.customerId\? Links match exactly/);
122
+ });
123
+
124
+ test("a producer whose outputs can't all be read, such as a view's columns, keeps the link unresolved, not missing", () => {
125
+ const decl = parseDeclaration(
126
+ declaration([
127
+ { name: "db", dir: "db", kind: "chant" },
128
+ { name: "analytics", dir: "analytics", kind: "dbt" },
129
+ ]),
130
+ "chant.workspace.json",
131
+ );
132
+ const handles = new Map<string, MemberHandles>([
133
+ ["db", { member: "db", outputs: [{ name: "public.orders" }], inputs: [], complete: false, why: "the columns of view public.orders can't be derived statically", exposesNone: false }],
134
+ ]);
135
+ const read = new Map([["analytics", { reader: "dbt-project" as const, gaps: [], producerField: "sqlMember", links: [{ member: null, output: "public.orders.customer_id", anchor: "public.orders", file: "analytics/models/s.yml", line: 4 }] }]]);
136
+ const [row] = resolveLinks(decl, handles, undefined, read);
137
+ expect(row).toMatchObject({ origin: "kind-reader", producer: "db", status: "unresolved", file: "analytics/models/s.yml", line: 4 });
138
+ expect((row as { reason: string }).reason).toMatch(/whose outputs could not all be read in source \(the columns of view public\.orders/);
139
+ });
140
+
141
+ test("a column the sql member renamed says what it was renamed to (#3931's previously hint)", () => {
142
+ const decl = parseDeclaration(
143
+ declaration([
144
+ { name: "db", dir: "db", kind: "chant" },
145
+ { name: "analytics", dir: "analytics", kind: "dbt", fields: { sqlMember: "db" } },
146
+ ]),
147
+ "chant.workspace.json",
148
+ );
149
+ const handles = new Map<string, MemberHandles>([
150
+ ["db", { member: "db", outputs: [{ name: "public.orders" }, { name: "public.orders.buyer_id", previously: ["public.orders.customer_id"] }], inputs: [], complete: true, why: null, exposesNone: false }],
151
+ ]);
152
+ const read = new Map([["analytics", { reader: "dbt-project" as const, gaps: [], producerField: "sqlMember", links: [{ member: "db", output: "public.orders.customer_id", anchor: "public.orders", file: "analytics/models/s.yml", line: 4 }] }]]);
153
+ const [row] = resolveLinks(decl, handles, undefined, read);
154
+ expect(row).toMatchObject({ origin: "kind-reader", status: "missing" });
155
+ expect((row as { reason: string }).reason).toMatch(/it was renamed to public\.orders\.buyer_id, so link to that instead/);
156
+ });
157
+
158
+ test("without sqlMember, the one member that exposes the table is the producer", async () => {
159
+ const report = await runDeclarationChecks(workspace({ dbtFields: {} }));
160
+ expect(report.diagnostics).toEqual([]);
161
+ expect(rows(report.links)[2]).toBe("analytics -> db.public.orders.customer_id resolved analytics/models/staging/sources.yml:10");
162
+ });
163
+
164
+ test("without sqlMember, a column dropped from the only producer still fails", async () => {
165
+ const report = await runDeclarationChecks(workspace({ dbtFields: {}, outputs: ["public.orders", "public.orders.id"] }));
166
+ expect(report.diagnostics.map((d) => `${d.ruleId}:${d.line}`)).toEqual(["WSP093:10"]);
167
+ });
168
+
169
+ test("without sqlMember, a table two members expose is ambiguous, and the message names the field", async () => {
170
+ const report = await runDeclarationChecks(
171
+ workspace({ dbtFields: {}, members: [{ name: "replica", dir: "replica", kind: "other", because: "a copy", suppress: [{ check: "WSP009", because: "a stand-in" }], outputs: ORDERS }], files: { "replica/README": "" } }),
172
+ );
173
+ expect(rows(report.links)[0]).toBe("analytics -> ? public.orders ambiguous db|replica");
174
+ const ids = report.diagnostics.map((d) => `${d.ruleId}:${d.severity}:${d.file}:${d.line}`);
175
+ expect(ids).toContain("WSP095:warning:analytics/models/staging/sources.yml:7");
176
+ expect(report.diagnostics.find((d) => d.ruleId === "WSP095")!.message).toMatch(/set fields\.sqlMember on analytics to name it/);
177
+ });
178
+
179
+ test("a sqlMember that names no member is invalid, reported against the field", async () => {
180
+ const report = await runDeclarationChecks(workspace({ dbtFields: { sqlMember: "warehouse" } }));
181
+ expect(rows(report.links)[0]).toBe("analytics -> warehouse.public.orders invalid analytics/models/staging/sources.yml:7");
182
+ expect(report.links.find((r) => r.origin === "kind-reader")!.reason).toMatch(/^fields\.sqlMember: member analytics links to warehouse, which is not a member/);
183
+ });
184
+
185
+ test("a templated schema is a gap (WSP099), never rendered, and its tables are not linked", async () => {
186
+ const report = await runDeclarationChecks(
187
+ workspace({
188
+ files: {
189
+ "analytics/models/raw.yml": "version: 2\nsources:\n - name: raw\n schema: \"{{ target.schema }}_raw\"\n tables:\n - name: events\n",
190
+ },
191
+ }),
192
+ );
193
+ expect(report.ok).toBe(true);
194
+ expect(report.diagnostics.map((d) => `${d.ruleId}:${d.severity}:${d.file}:${d.line}`)).toEqual(["WSP099:info:analytics/models/raw.yml:4"]);
195
+ expect(report.diagnostics[0].message).toMatch(/source raw: schema .* is a Jinja template or not a name; chant reads names as declared and never renders them for a target/);
196
+ expect(rows(report.links).some((r) => r.includes("events"))).toBe(false);
197
+ });
198
+ });
199
+
200
+ describe("the dbt-project reader (#3932)", () => {
201
+ const read = (files: Record<string, string>, fields: Record<string, unknown> = {}) => {
202
+ const root = repo({ "dbt_project.yml": "name: p\n", ...files });
203
+ return readDbtProject({
204
+ member: { name: "p" } as never,
205
+ dir: "",
206
+ fields: { sqlMember: null, dialect: "postgres", ...fields } as never,
207
+ tree: workingTree(root),
208
+ });
209
+ };
210
+ const outputs = (r: ReturnType<typeof read>) => r.links.map((l) => l.output);
211
+
212
+ test("the schema defaults to the source name, as dbt's does, and identifier overrides the table name", () => {
213
+ const r = read({ "models/s.yml": "sources:\n - name: shop\n tables:\n - name: orders\n identifier: orders_v2\n" });
214
+ expect(outputs(r)).toEqual(["shop.orders_v2"]);
215
+ expect(r.gaps).toEqual([]);
216
+ });
217
+
218
+ test("postgres: dbt quotes schema and identifier by default, so they keep their case; unquoted names fold", () => {
219
+ const sources = (quoting: string) =>
220
+ `sources:\n - name: Shop\n${quoting} tables:\n - name: Orders\n columns:\n - name: CustomerId\n - name: OrderId\n quote: true\n`;
221
+ expect(outputs(read({ "models/s.yml": sources("") }))).toEqual(["Shop.Orders", "Shop.Orders.customerid", "Shop.Orders.OrderId"]);
222
+ expect(outputs(read({ "models/s.yml": sources(" quoting:\n schema: false\n identifier: false\n") }))).toEqual([
223
+ "shop.orders",
224
+ "shop.orders.customerid",
225
+ "shop.orders.OrderId",
226
+ ]);
227
+ // dbt_project.yml's quoting applies below it.
228
+ expect(outputs(read({ "dbt_project.yml": "name: p\nquoting:\n identifier: false\n", "models/s.yml": sources("") }))[0]).toBe("Shop.orders");
229
+ });
230
+
231
+ test("clickhouse keeps every name as written; the source schema is the ClickHouse database", () => {
232
+ const r = read({ "models/s.yml": "sources:\n - name: events\n schema: Analytics\n tables:\n - name: PageViews\n columns:\n - name: UserId\n" }, { dialect: "clickhouse" });
233
+ expect(outputs(r)).toEqual(["Analytics.PageViews", "Analytics.PageViews.UserId"]);
234
+ });
235
+
236
+ test("model-paths say where sources live, and a disabled source or table is left out", () => {
237
+ const r = read({
238
+ "dbt_project.yml": "name: p\nmodel-paths: [transform]\n",
239
+ "models/ignored.yml": "sources:\n - name: a\n tables:\n - name: t\n",
240
+ "transform/nested/s.yaml":
241
+ "sources:\n - name: b\n tables:\n - name: kept\n - name: off\n config:\n enabled: false\n - name: c\n enabled: false\n tables:\n - name: t\n",
242
+ });
243
+ expect(outputs(r)).toEqual(["b.kept"]);
244
+ });
245
+
246
+ test("templated names are gaps with their lines, and the rest of the file is still read", () => {
247
+ const r = read({
248
+ "models/s.yml":
249
+ "sources:\n - name: shop\n tables:\n - name: orders\n identifier: \"{{ var('orders') }}\"\n - name: items\n columns:\n - name: \"{{ env_var('COL') }}\"\n - name: sku\n",
250
+ });
251
+ expect(outputs(r)).toEqual(["shop.items", "shop.items.sku"]);
252
+ expect(r.gaps.map((g) => `${g.file}:${g.line}`)).toEqual(["models/s.yml:5", "models/s.yml:8"]);
253
+ });
254
+
255
+ test("a table named like its source gets its own line; a column named like its table too", () => {
256
+ const r = read({ "models/s.yml": "sources:\n - name: orders\n schema: public\n tables:\n - name: orders\n columns:\n - name: orders\n" });
257
+ expect(r.links.map((l) => `${l.output}@${l.line}`)).toEqual(["public.orders@5", "public.orders.orders@7"]);
258
+ });
259
+
260
+ test("YAML that doesn't parse is a gap at its line", () => {
261
+ const r = read({ "models/s.yml": "sources:\n - name: a\n tables: [\n" });
262
+ expect(r.links).toEqual([]);
263
+ expect(r.gaps).toHaveLength(1);
264
+ expect(r.gaps[0].file).toBe("models/s.yml");
265
+ });
266
+ });
267
+
268
+ describe("dbt links in chant workspace graph (#3932)", () => {
269
+ test("graphLinks carries the reader's rows with their file and line, valid against graph.schema.json", () => {
270
+ const root = workspace();
271
+ const tree = workingTree(root);
272
+ const decl = parseDeclaration(tree.read("chant.workspace.json"), "chant.workspace.json");
273
+ const kinds = createKindRegistry(parseKindData(SQL_KINDS, "@intentius/chant-lexicon-sql").kinds);
274
+ const links = graphLinks(decl, { composed: [], exports: [], imports: [] }, kinds, readKindLinks(decl, tree, kinds));
275
+ expect(rows(links)).toEqual([
276
+ "analytics -> db.public.orders resolved analytics/models/staging/sources.yml:7",
277
+ "analytics -> db.public.orders.id resolved analytics/models/staging/sources.yml:9",
278
+ "analytics -> db.public.orders.customer_id resolved analytics/models/staging/sources.yml:10",
279
+ ]);
280
+ const linkSchema = { $schema: "https://json-schema.org/draft/2020-12/schema", $defs: (graphSchema as { $defs: object }).$defs, type: "array", items: { $ref: "#/$defs/link" } };
281
+ contract(linkSchema).expectValid(links);
282
+ });
283
+ });
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The `dbt-project` link reader (#3932): a dbt project's sources, as links to
3
+ * the tables and columns a sql member exposes (#3931).
4
+ *
5
+ * The reader parses YAML and nothing else. It never runs dbt, never renders
6
+ * Jinja and never reads `profiles.yml` or a target, so the links it derives
7
+ * are the same in every environment.
8
+ *
9
+ * - The project is `dbt_project.yml`. Sources are read from every `.yml` and
10
+ * `.yaml` file under its `model-paths` (`source-paths` in older projects;
11
+ * `models` when it names neither), as dbt reads them.
12
+ * - Each enabled source table links to `<schema>.<table>`, and each of its
13
+ * `columns` to `<schema>.<table>.<column>`. The schema is the source's
14
+ * `schema`, or the source's `name` when it sets none, which is dbt's own
15
+ * fallback. The table is its `identifier`, or its `name`. `database` is not
16
+ * part of the name: a Postgres output has none, and dbt-clickhouse reads a
17
+ * source's `schema` as the ClickHouse database.
18
+ * - Case follows the member's `dialect` field. For `postgres`, an unquoted
19
+ * name folds to lowercase and a quoted one keeps its case. dbt quotes a
20
+ * source's schema and identifier on Postgres unless `quoting:` (in
21
+ * `dbt_project.yml`, on the source, or on the table) turns it off, and a
22
+ * column is quoted only with `quote: true`. For `clickhouse`, names are
23
+ * kept as written.
24
+ * - A value that is a Jinja template (`{{ ... }}` or `{% ... %}`) is not
25
+ * resolved: the reader records a gap and leaves that source, table or
26
+ * column out, rather than guess what a target would render.
27
+ * - The producer is the member the `sqlMember` field names. Unset, each link
28
+ * leaves the producer to `resolveLinks`, which picks the one member that
29
+ * exposes the table.
30
+ */
31
+
32
+ import { posix } from "node:path";
33
+ import { parseYAMLDocument, YAMLParseError } from "../yaml";
34
+ import type { KindLinkRead, KindReadGap, KindReadLink, KindReaderInput } from "./kind-link-readers";
35
+ import { joinPath, skippedDir, type WorkspaceTree } from "./tree";
36
+
37
+ export const DBT_PROJECT_FILE = "dbt_project.yml";
38
+ const DEFAULT_MODEL_PATHS = ["models"];
39
+
40
+ /** The dialects whose naming the reader follows; the member's `dialect` field picks one. */
41
+ export const DBT_DIALECTS = ["postgres", "clickhouse"] as const;
42
+ type Dialect = (typeof DBT_DIALECTS)[number];
43
+
44
+ interface Quoting {
45
+ schema: boolean;
46
+ identifier: boolean;
47
+ }
48
+
49
+ const isTemplated = (v: string) => /\{\{|\{%/.test(v);
50
+ const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
51
+
52
+ export function readDbtProject(input: KindReaderInput): KindLinkRead {
53
+ const { dir, tree, fields } = input;
54
+ const links: KindReadLink[] = [];
55
+ const gaps: KindReadGap[] = [];
56
+ const result: KindLinkRead = { reader: "dbt-project", links, gaps, producerField: "sqlMember" };
57
+ const projectFile = joinPath(dir, DBT_PROJECT_FILE);
58
+
59
+ const producer = typeof fields?.sqlMember === "string" && fields.sqlMember !== "" ? fields.sqlMember : null;
60
+ const dialect: Dialect = fields?.dialect === "clickhouse" ? "clickhouse" : "postgres";
61
+
62
+ let project: unknown;
63
+ try {
64
+ project = parseYAMLDocument(tree.read(projectFile));
65
+ } catch (err) {
66
+ gaps.push({ file: projectFile, line: err instanceof YAMLParseError ? err.line : 1, message: `${DBT_PROJECT_FILE} could not be read (${(err as Error).message}), so no source was read` });
67
+ return result;
68
+ }
69
+ if (!isObject(project)) {
70
+ gaps.push({ file: projectFile, line: 1, message: `${DBT_PROJECT_FILE} is not a mapping, so no source was read` });
71
+ return result;
72
+ }
73
+ const projectLines = lines(tree.read(projectFile));
74
+
75
+ // Where sources live: model-paths, or the older source-paths.
76
+ const key = project["model-paths"] !== undefined ? "model-paths" : project["source-paths"] !== undefined ? "source-paths" : null;
77
+ const rawPaths = key ? project[key] : DEFAULT_MODEL_PATHS;
78
+ const paths: string[] = [];
79
+ if (!Array.isArray(rawPaths)) {
80
+ gaps.push({ file: projectFile, line: keyLine(projectLines, key ?? ""), message: `${key} is not a list, so no source was read` });
81
+ return result;
82
+ }
83
+ for (const p of rawPaths) {
84
+ if (typeof p !== "string" || isTemplated(p)) {
85
+ gaps.push({ file: projectFile, line: keyLine(projectLines, key ?? ""), message: `${key} entry ${JSON.stringify(p)} is not a plain path; its sources were not read` });
86
+ continue;
87
+ }
88
+ const at = posix.normalize(joinPath(dir, p));
89
+ if (at.startsWith("../") || at === ".." || posix.isAbsolute(p)) {
90
+ gaps.push({ file: projectFile, line: keyLine(projectLines, key ?? ""), message: `${key} entry ${p} lies outside the workspace; its sources were not read` });
91
+ continue;
92
+ }
93
+ paths.push(at === "." ? "" : at);
94
+ }
95
+
96
+ const quoting = readQuoting({ schema: true, identifier: true }, project.quoting, projectFile, keyLine(projectLines, "quoting"), "dbt_project.yml quoting", gaps);
97
+
98
+ const files = new Set<string>();
99
+ for (const p of paths) for (const f of yamlFiles(tree, p)) files.add(f);
100
+ const name = (v: string, quoted: boolean) => (dialect === "postgres" && !quoted ? v.toLowerCase() : v);
101
+
102
+ for (const file of [...files].sort()) {
103
+ let text: string;
104
+ let doc: unknown;
105
+ try {
106
+ text = tree.read(file);
107
+ doc = parseYAMLDocument(text);
108
+ } catch (err) {
109
+ gaps.push({ file, line: err instanceof YAMLParseError ? err.line : 1, message: `the file could not be read as YAML (${(err as Error).message}); any sources in it were not read` });
110
+ continue;
111
+ }
112
+ if (!isObject(doc) || doc.sources === undefined || doc.sources === null) continue;
113
+ const fileLines = lines(text);
114
+ const sourcesLine = keyLine(fileLines, "sources");
115
+ if (!Array.isArray(doc.sources)) {
116
+ gaps.push({ file, line: sourcesLine, message: "sources is not a list; its sources were not read" });
117
+ continue;
118
+ }
119
+ let cursor = sourcesLine;
120
+ for (const source of doc.sources) {
121
+ if (!isObject(source)) continue;
122
+ const sourceName = scalar(source.name);
123
+ const sourceLine = nameLine(fileLines, sourceName, cursor);
124
+ cursor = sourceLine;
125
+ if (sourceName === undefined) {
126
+ gaps.push({ file, line: sourceLine, message: "a source has no name; its tables were not read" });
127
+ continue;
128
+ }
129
+ const where = `source ${sourceName}`;
130
+ if (isTemplated(sourceName)) {
131
+ gaps.push({ file, line: sourceLine, message: `${where}: its name is a Jinja template, which chant does not render; its tables were not read` });
132
+ continue;
133
+ }
134
+ const enabled = readEnabled(source, file, sourceLine, where, gaps);
135
+ if (enabled !== true) continue;
136
+ const schema = source.schema === undefined || source.schema === null ? sourceName : scalar(source.schema);
137
+ if (schema === undefined || isTemplated(schema)) {
138
+ gaps.push({
139
+ file,
140
+ line: valueLine(fileLines, "schema", sourceLine),
141
+ message: `${where}: schema ${JSON.stringify(source.schema)} is a Jinja template or not a name; chant reads names as declared and never renders them for a target, so its tables were not read`,
142
+ });
143
+ continue;
144
+ }
145
+ const sourceQuoting = readQuoting(quoting, source.quoting, file, sourceLine, `${where} quoting`, gaps);
146
+ if (source.tables === undefined || source.tables === null) continue;
147
+ if (!Array.isArray(source.tables)) {
148
+ gaps.push({ file, line: sourceLine, message: `${where}: tables is not a list; its tables were not read` });
149
+ continue;
150
+ }
151
+ let tableCursor = sourceLine;
152
+ for (const table of source.tables) {
153
+ if (!isObject(table)) continue;
154
+ const tableName = scalar(table.name);
155
+ const tableLine = nameLine(fileLines, tableName, tableCursor);
156
+ tableCursor = tableLine;
157
+ if (tableName === undefined) {
158
+ gaps.push({ file, line: tableLine, message: `${where}: a table has no name; it was not read` });
159
+ continue;
160
+ }
161
+ const tableWhere = `${where} table ${tableName}`;
162
+ if (isTemplated(tableName)) {
163
+ gaps.push({ file, line: tableLine, message: `${tableWhere}: its name is a Jinja template, which chant does not render; it was not read` });
164
+ continue;
165
+ }
166
+ if (readEnabled(table, file, tableLine, tableWhere, gaps) !== true) continue;
167
+ const identifier = table.identifier === undefined || table.identifier === null ? tableName : scalar(table.identifier);
168
+ if (identifier === undefined || isTemplated(identifier)) {
169
+ gaps.push({
170
+ file,
171
+ line: valueLine(fileLines, "identifier", tableLine),
172
+ message: `${tableWhere}: identifier ${JSON.stringify(table.identifier)} is a Jinja template or not a name, which chant does not render; it was not read`,
173
+ });
174
+ continue;
175
+ }
176
+ const q = readQuoting(sourceQuoting, table.quoting, file, tableLine, `${tableWhere} quoting`, gaps);
177
+ const tableOutput = `${name(schema, q.schema)}.${name(identifier, q.identifier)}`;
178
+ links.push({ member: producer, output: tableOutput, file, line: tableLine });
179
+ if (table.columns === undefined || table.columns === null) continue;
180
+ if (!Array.isArray(table.columns)) {
181
+ gaps.push({ file, line: tableLine, message: `${tableWhere}: columns is not a list; its columns were not read` });
182
+ continue;
183
+ }
184
+ let columnCursor = tableLine;
185
+ for (const column of table.columns) {
186
+ if (!isObject(column)) continue;
187
+ const columnName = scalar(column.name);
188
+ const columnLine = nameLine(fileLines, columnName, columnCursor);
189
+ columnCursor = columnLine;
190
+ if (columnName === undefined || isTemplated(columnName)) {
191
+ gaps.push({ file, line: columnLine, message: `${tableWhere}: column ${JSON.stringify(column.name)} is a Jinja template or not a name, which chant does not render; it was not read` });
192
+ continue;
193
+ }
194
+ const quoted = column.quote === true;
195
+ links.push({ member: producer, output: `${tableOutput}.${name(columnName, quoted)}`, anchor: tableOutput, file, line: columnLine });
196
+ }
197
+ }
198
+ }
199
+ }
200
+ return result;
201
+ }
202
+
203
+ /** A name as text: a string, or a number or boolean YAML read as one. */
204
+ function scalar(v: unknown): string | undefined {
205
+ if (typeof v === "string") return v;
206
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
207
+ return undefined;
208
+ }
209
+
210
+ /** Whether an entry is enabled: `enabled`, or `config.enabled`. A templated value is a gap, and the entry is left out. */
211
+ function readEnabled(entry: Record<string, unknown>, file: string, line: number, where: string, gaps: KindReadGap[]): boolean {
212
+ const config = isObject(entry.config) ? entry.config : {};
213
+ const raw = entry.enabled ?? config.enabled;
214
+ if (raw === undefined || raw === null || raw === true) return true;
215
+ if (raw === false) return false;
216
+ gaps.push({ file, line, message: `${where}: enabled is ${JSON.stringify(raw)}, which chant does not render; it was not read` });
217
+ return false;
218
+ }
219
+
220
+ /** `base` with a `quoting:` block's schema and identifier applied. A value that isn't a boolean is a gap and keeps the base. */
221
+ function readQuoting(base: Quoting, raw: unknown, file: string, line: number, where: string, gaps: KindReadGap[]): Quoting {
222
+ if (raw === undefined || raw === null) return base;
223
+ if (!isObject(raw)) {
224
+ gaps.push({ file, line, message: `${where} is not a mapping; the quoting above it applies` });
225
+ return base;
226
+ }
227
+ const out = { ...base };
228
+ for (const key of ["schema", "identifier"] as const) {
229
+ const v = raw[key];
230
+ if (v === undefined) continue;
231
+ if (typeof v === "boolean") out[key] = v;
232
+ else gaps.push({ file, line, message: `${where}.${key} is ${JSON.stringify(v)}, not true or false; the quoting above it applies` });
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /** Every `.yml` and `.yaml` file under tree-relative directory `dir`, skipping dot directories and node_modules. */
238
+ function yamlFiles(tree: WorkspaceTree, dir: string): string[] {
239
+ if (dir !== "" && tree.stat(dir) !== "dir") return [];
240
+ const out: string[] = [];
241
+ const walk = (at: string) => {
242
+ for (const e of tree.list(at) ?? []) {
243
+ const path = joinPath(at, e.name);
244
+ if (e.type === "dir") {
245
+ if (!skippedDir(e.name)) walk(path);
246
+ } else if (/\.ya?ml$/.test(e.name)) {
247
+ out.push(path);
248
+ }
249
+ }
250
+ };
251
+ walk(dir);
252
+ return out;
253
+ }
254
+
255
+ function lines(text: string): string[] {
256
+ return text.replace(/\r\n/g, "\n").split("\n");
257
+ }
258
+
259
+ function escapeRegExp(text: string): string {
260
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
261
+ }
262
+
263
+ /** The line (from 1) of top-level key `key`, or 1. */
264
+ function keyLine(fileLines: string[], key: string): number {
265
+ const re = new RegExp(`^${escapeRegExp(key)}\\s*:`);
266
+ const i = fileLines.findIndex((l) => re.test(l));
267
+ return i < 0 ? 1 : i + 1;
268
+ }
269
+
270
+ /**
271
+ * The line of the first `name: <value>` entry after line `from`, or `from`
272
+ * itself. Sources, tables and columns are listed in file order, so
273
+ * searching on from the parent's line finds each one's own line. A file in
274
+ * flow style has no such lines, and its entries are reported at the parent.
275
+ */
276
+ function nameLine(fileLines: string[], value: string | undefined, from: number): number {
277
+ return value === undefined ? from : valueLineOf(fileLines, "name", value, from);
278
+ }
279
+
280
+ /** The line of the first `<key>:` after line `from`, or `from`. */
281
+ function valueLine(fileLines: string[], key: string, from: number): number {
282
+ const re = new RegExp(`^\\s*(?:-\\s+)?${escapeRegExp(key)}\\s*:`);
283
+ for (let i = Math.max(from, 0); i < fileLines.length; i++) if (re.test(fileLines[i])) return i + 1;
284
+ return from;
285
+ }
286
+
287
+ function valueLineOf(fileLines: string[], key: string, value: string, from: number): number {
288
+ const re = new RegExp(`^\\s*(?:-\\s+)?${escapeRegExp(key)}\\s*:\\s*(["']?)${escapeRegExp(value)}\\1\\s*(?:#.*)?$`);
289
+ for (let i = Math.max(from, 0); i < fileLines.length; i++) if (re.test(fileLines[i])) return i + 1;
290
+ return from;
291
+ }
@@ -36,6 +36,7 @@ import { openGraphCache, splitCached, storeReads, withoutUnits } from "./graph-c
36
36
  import { readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type ErrorLocation } from "./declaration";
37
37
  import { describePlan, emitDocument, executePlan, memberStatus, planJson, planMembers, type MemberPlan, type Toolchain, type UnitResult } from "./member-commands";
38
38
  import { loadKindRegistry } from "./kinds";
39
+ import { readKindLinks } from "./kind-link-readers";
39
40
  import { prepareKindReaders, type PreparedReaders } from "./kind-readers";
40
41
  import { recordLinkRows } from "./record-assets";
41
42
  import { RecordReadError } from "./records";
@@ -258,7 +259,8 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
258
259
  member.stamp = split?.stamps.get(member.name) ?? null;
259
260
  }
260
261
  // Links (#2539) resolve against the declaration that was read, the revision's for --at, and the kinds installed now.
261
- const graph = composeWorkspaceGraph({ name: declaration.name, root: located.root }, inputs, { declaration, kinds });
262
+ // A kind's link reader (#3932) reads the member's files in the tree read, as check does.
263
+ const graph = composeWorkspaceGraph({ name: declaration.name, root: located.root }, inputs, { declaration, kinds, read: readKindLinks(declaration, located.tree, kinds) });
262
264
  // The box's factory and listing (#3146), from the declaration read, so --at gives them at that revision.
263
265
  for (const member of graph.members) member.box = graphBox(declaration.members.find((m) => m.name === member.name)?.box ?? null, located.tree, declaration.members);
264
266
  // Nested workspaces (#2551, ws-071): read-only, through their own chant workspace graph.
@@ -15,7 +15,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, utimesSync, writeFileSyn
15
15
  import { join } from "node:path";
16
16
  import { afterAll, describe, expect, test } from "vitest";
17
17
  import { cleanScratch, commitAll, contract, declaration, declaration as declaration_, FAKE_FILE_GRAPH_CHANT, FAKE_GRAPH_CHANT, git, REPO, repo, scratchDir, validSchema } from "./__fixtures__/contract-repo";
18
- import type { GraphIR } from "../graph-ir";
18
+ import type { GraphIR, IREdgeKind } from "../graph-ir";
19
19
  import { composeWorkspaceGraph, MEMBER_RUN_REASON_CODES } from "./compose-graph";
20
20
  import { parseDeclaration, WORKSPACE_ERROR_CODES } from "./declaration";
21
21
  import type { LinkTableRow } from "./links";
@@ -106,6 +106,31 @@ describe("the links section (#2539)", () => {
106
106
  });
107
107
  });
108
108
 
109
+ describe("CI edges, pipelines and dangling references (#3922)", () => {
110
+ test("the edge kinds match IREdgeKind, and a composed CI member validates", () => {
111
+ const kinds: IREdgeKind[] = ["ref", "needs", "calls", "triggers", "includes", "extends"];
112
+ expect(schema.$defs.edge.properties.kind.enum).toEqual(kinds);
113
+ const ir: GraphIR = {
114
+ version: 1,
115
+ nodes: [
116
+ { id: "ci", kind: "Workflow", lexicon: "github", attrs: {} },
117
+ { id: "build", kind: "Job", lexicon: "github", attrs: {} },
118
+ { id: "deploy", kind: "Job", lexicon: "github", attrs: {} },
119
+ ],
120
+ edges: [
121
+ { from: "deploy", to: "build", kind: "needs", viaAttr: "needs" },
122
+ { from: "build", to: "ci", kind: "calls", viaAttr: "uses", crossPipeline: true },
123
+ ],
124
+ groups: { byPipeline: { ci: ["build", "ci", "deploy"] } },
125
+ dangling: [{ from: "deploy", kind: "needs", path: "needs", value: "lint" }, { from: "build", kind: "calls", path: "uses", value: "o/r/.github/workflows/x.yml@v1", crossPipeline: true }],
126
+ };
127
+ const m = { name: "svc", dir: "svc", kind: "chant", status: "composed" as const, reason: null, chant: "0.124.20", irVersion: 1, live: false };
128
+ const graph = composeWorkspaceGraph({ name: "acme", root: "." }, [{ member: m, ir }]);
129
+ expect(graph.dangling).toHaveLength(2);
130
+ expectValid({ $schema: GRAPH_OUTPUT_SCHEMA_ID, contract: 1, chant: "0.124.20", at: null, ...graph });
131
+ });
132
+ });
133
+
109
134
  describe("the collectors section (#2559)", () => {
110
135
  const topology = {
111
136
  pipelines: [{ id: "traces", signal: "traces", receivers: ["otlp"], processors: [], exporters: ["otlphttp"] }],