@intentius/chant 0.124.21 → 0.124.23

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 (95) hide show
  1. package/dist/audit/rules-doc.js +1 -1
  2. package/dist/cli/handlers/graph.js +24 -1
  3. package/dist/codegen/docs-pages.d.ts +1 -0
  4. package/dist/codegen/docs-pages.d.ts.map +1 -1
  5. package/dist/codegen/docs-pages.js +1 -0
  6. package/dist/codegen/docs-sections.d.ts +2 -0
  7. package/dist/codegen/docs-sections.d.ts.map +1 -1
  8. package/dist/codegen/docs-sections.js +8 -0
  9. package/dist/codegen/docs-types.d.ts +7 -0
  10. package/dist/codegen/docs-types.d.ts.map +1 -1
  11. package/dist/codegen/docs.d.ts.map +1 -1
  12. package/dist/codegen/docs.js +20 -0
  13. package/dist/fold/subset.d.ts +3 -1
  14. package/dist/fold/subset.d.ts.map +1 -1
  15. package/dist/fold/subset.js +3 -1
  16. package/dist/graph-ir.d.ts +43 -0
  17. package/dist/graph-ir.d.ts.map +1 -1
  18. package/dist/graph-ir.js +27 -0
  19. package/dist/lexicon.d.ts +25 -1
  20. package/dist/lexicon.d.ts.map +1 -1
  21. package/dist/lint/rules/evl004-spread-non-const.d.ts.map +1 -1
  22. package/dist/lint/rules/evl004-spread-non-const.js +19 -1
  23. package/dist/wave-runtime.mjs +222 -206
  24. package/dist/workspace/checks/links.d.ts +5 -0
  25. package/dist/workspace/checks/links.d.ts.map +1 -1
  26. package/dist/workspace/checks/links.js +54 -8
  27. package/dist/workspace/checks.d.ts +4 -1
  28. package/dist/workspace/checks.d.ts.map +1 -1
  29. package/dist/workspace/checks.js +6 -2
  30. package/dist/workspace/compose-graph.d.ts +4 -1
  31. package/dist/workspace/compose-graph.d.ts.map +1 -1
  32. package/dist/workspace/compose-graph.js +3 -2
  33. package/dist/workspace/dbt-project-reader.d.ts +36 -0
  34. package/dist/workspace/dbt-project-reader.d.ts.map +1 -0
  35. package/dist/workspace/dbt-project-reader.js +290 -0
  36. package/dist/workspace/graph-cli.d.ts.map +1 -1
  37. package/dist/workspace/graph-cli.js +3 -1
  38. package/dist/workspace/graph.schema.json +41 -3
  39. package/dist/workspace/intent.js +7 -2
  40. package/dist/workspace/kind-link-readers.d.ts +84 -0
  41. package/dist/workspace/kind-link-readers.d.ts.map +1 -0
  42. package/dist/workspace/kind-link-readers.js +53 -0
  43. package/dist/workspace/kinds.d.ts +15 -0
  44. package/dist/workspace/kinds.d.ts.map +1 -1
  45. package/dist/workspace/kinds.js +11 -0
  46. package/dist/workspace/lexicon-outputs.d.ts +63 -0
  47. package/dist/workspace/lexicon-outputs.d.ts.map +1 -0
  48. package/dist/workspace/lexicon-outputs.js +241 -0
  49. package/dist/workspace/links.d.ts +29 -7
  50. package/dist/workspace/links.d.ts.map +1 -1
  51. package/dist/workspace/links.js +109 -34
  52. package/dist/workspace/member-handles.d.ts +18 -1
  53. package/dist/workspace/member-handles.d.ts.map +1 -1
  54. package/dist/workspace/member-handles.js +31 -14
  55. package/dist/workspace/records-checkout.d.ts +11 -1
  56. package/dist/workspace/records-checkout.d.ts.map +1 -1
  57. package/dist/workspace/records-checkout.js +38 -2
  58. package/dist/workspace/status.d.ts.map +1 -1
  59. package/dist/workspace/status.js +3 -2
  60. package/dist/workspace/workspace-kinds.schema.json +5 -0
  61. package/package.json +1 -1
  62. package/src/audit/rules-doc.ts +1 -1
  63. package/src/cli/handlers/graph.ts +22 -1
  64. package/src/codegen/docs-pages.ts +1 -0
  65. package/src/codegen/docs-sections.ts +8 -0
  66. package/src/codegen/docs-site/AgentPrompt.astro +153 -0
  67. package/src/codegen/docs-types.ts +7 -0
  68. package/src/codegen/docs.ts +27 -0
  69. package/src/fold/subset.ts +3 -1
  70. package/src/graph-ir.test.ts +23 -1
  71. package/src/graph-ir.ts +65 -0
  72. package/src/lexicon.ts +26 -1
  73. package/src/lint/rules/evl004-spread-non-const.test.ts +18 -0
  74. package/src/lint/rules/evl004-spread-non-const.ts +16 -1
  75. package/src/wave-loop-golden.test.ts +3 -0
  76. package/src/workspace/checks/links.ts +57 -8
  77. package/src/workspace/checks.e2e.test.ts +1 -1
  78. package/src/workspace/checks.ts +9 -2
  79. package/src/workspace/compose-graph.ts +5 -3
  80. package/src/workspace/dbt-kind.e2e.test.ts +95 -0
  81. package/src/workspace/dbt-kind.test.ts +283 -0
  82. package/src/workspace/dbt-project-reader.ts +291 -0
  83. package/src/workspace/graph-cli.ts +3 -1
  84. package/src/workspace/graph.schema.json +41 -3
  85. package/src/workspace/intent.ts +7 -2
  86. package/src/workspace/kind-link-readers.ts +109 -0
  87. package/src/workspace/kinds.ts +21 -1
  88. package/src/workspace/lexicon-outputs.ts +272 -0
  89. package/src/workspace/links.test.ts +22 -0
  90. package/src/workspace/links.ts +129 -34
  91. package/src/workspace/member-handles.ts +28 -8
  92. package/src/workspace/records-checkout.ts +36 -2
  93. package/src/workspace/records-uncommitted.test.ts +5 -1
  94. package/src/workspace/status.ts +3 -2
  95. package/src/workspace/workspace-kinds.schema.json +5 -0
@@ -24,7 +24,7 @@
24
24
  * | WSP001 to WSP011 | the declaration and member kinds (this file) |
25
25
  * | WSP071, WSP072 | member ledgers |
26
26
  * | WSP081 to WSP083 | recorded pipelines |
27
- * | WSP091 to WSP098 | member links (#2539, #2558) |
27
+ * | WSP091 to WSP099 | member links (#2539, #2558), and those a kind's reader finds in a member's files (#3932) |
28
28
  * | WSP101 to WSP106 | generated files |
29
29
  * | WSP111 to WSP114 | records read with `--kind` (#2549) |
30
30
  * | WSP115 | the record kinds the declaration names (#2680) |
@@ -59,6 +59,7 @@ import { BOX_CHECKS } from "./checks/boxes";
59
59
  import { DIAGRAM_CHECKS } from "./checks/diagrams";
60
60
  import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
61
61
  import { resolveBoxIntents, type ResolvedBoxIntent } from "./box-intent";
62
+ import { gatherLexiconOutputs, type MemberLexiconOutputs } from "./lexicon-outputs";
62
63
 
63
64
  /**
64
65
  * What the checks beyond the declaration read, gathered from the checkout
@@ -80,6 +81,8 @@ export interface WorkspaceFacts {
80
81
  live?: LiveLinkFacts;
81
82
  /** The decision record each box's intent names, read from the working tree (#2850). */
82
83
  boxIntents?: readonly ResolvedBoxIntent[];
84
+ /** The outputs linked members' lexicons report about their declarations, read by folding their source (#3931). */
85
+ lexiconOutputs?: ReadonlyMap<string, MemberLexiconOutputs>;
83
86
  }
84
87
 
85
88
  /** What every declaration check reads. */
@@ -355,7 +358,7 @@ export const WORKSPACE_CHECKS: readonly WorkspaceCheck[] = [
355
358
  ...LEDGER_CHECKS,
356
359
  // Recorded pipelines (#2542).
357
360
  ...PIPELINE_CHECKS,
358
- // Member links (#2539, #2558), WSP091 to WSP098.
361
+ // Member links (#2539, #2558, #3932), WSP091 to WSP099.
359
362
  ...LINK_CHECKS,
360
363
  // Generated files (#2541).
361
364
  ...GENERATED_CHECKS,
@@ -521,7 +524,11 @@ export async function runDeclarationChecks(
521
524
  const declaredKinds = options.gather === false ? undefined : await loadDeclaredKinds(declaration, tree, root, { load: !options.tree, acceptance: !options.tree });
522
525
  // A box's intent is read from the working tree's records, so not under --at (#2850).
523
526
  const boxIntents = declaredKinds && !options.tree ? await resolveBoxIntents(declaration, root, declaredKinds) : undefined;
527
+ // A linked member's lexicons name its tables and columns as outputs (#3931); reading them folds its source, never runs it.
528
+ const lexiconOutputs =
529
+ options.gather === false ? undefined : await gatherLexiconOutputs(root, declaration, options.tree ? workingTree(root) : tree, groups, registry, { atRevision: options.tree !== undefined });
524
530
  const facts: WorkspaceFacts = {
531
+ ...(lexiconOutputs && lexiconOutputs.size > 0 ? { lexiconOutputs } : {}),
525
532
  ...gathered,
526
533
  ...(options.records ? { records: options.records } : {}),
527
534
  ...(declaredKinds ? { declaredKinds } : {}),
@@ -33,6 +33,7 @@ import type { FactoryView, ListingView } from "./box-factory";
33
33
  import type { Declaration } from "./declaration";
34
34
  import type { KindRegistry } from "./kinds";
35
35
  import { graphLinks, type LinkTableRow } from "./links";
36
+ import type { KindLinkRead } from "./kind-link-readers";
36
37
  import type { RecordLinkRow } from "./record-assets";
37
38
  import type { ReasonCode } from "./reason-codes";
38
39
  import { GRAPH_IR_VERSION, type GraphIR, type IRExport, type IRGroups, type IRImport, type IREdge, type IRNode } from "../graph-ir";
@@ -302,12 +303,13 @@ function sorted(rec: Record<string, string[]>): Record<string, string[]> {
302
303
  * Compose the members' IRs into the workspace document. Members keep the
303
304
  * order they are given in. With `links`, the `links` section is filled from
304
305
  * the declaration; `kinds` lets a member that isn't composed but whose kind
305
- * lists its outputs (`other`, kinds from a package) be a link target.
306
+ * lists its outputs (`other`, kinds from a package) be a link target, and
307
+ * `read` holds the links each member's kind reader found in its files (#3932).
306
308
  */
307
309
  export function composeWorkspaceGraph(
308
310
  workspace: { name: string; root: string },
309
311
  inputs: ComposeInput[],
310
- links?: { declaration: Declaration; kinds?: KindRegistry },
312
+ links?: { declaration: Declaration; kinds?: KindRegistry; read?: ReadonlyMap<string, KindLinkRead> },
311
313
  ): WorkspaceGraph {
312
314
  const nodes: ComposedNode[] = [];
313
315
  const edges: ComposedEdge[] = [];
@@ -370,7 +372,7 @@ export function composeWorkspaceGraph(
370
372
  exports,
371
373
  imports,
372
374
  links: links
373
- ? graphLinks(links.declaration, { composed: inputs.filter((i) => i.ir).map((i) => i.member.name), collectors, exports, imports }, links.kinds)
375
+ ? graphLinks(links.declaration, { composed: inputs.filter((i) => i.ir).map((i) => i.member.name), collectors, exports, imports }, links.kinds, links.read)
374
376
  : [],
375
377
  collectors,
376
378
  records: [],
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The `dbt` kind end to end (#3932): the sql lexicon pinned as a package,
3
+ * its kinds file found through node_modules, and this checkout's chant CLI
4
+ * running `workspace check` and `workspace graph`. The producer is a real
5
+ * sql member, a chant project that declares `orders`, whose tables and
6
+ * columns are outputs through the sql lexicon's hook (#3931).
7
+ */
8
+
9
+ import { spawnSync } from "node:child_process";
10
+ import { readFileSync, symlinkSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { afterAll, describe, expect, test } from "vitest";
14
+ import { cleanScratch, contract, declaration, REPO, repo } from "./__fixtures__/contract-repo";
15
+ import schema from "./graph.schema.json";
16
+
17
+ afterAll(cleanScratch);
18
+
19
+ const TIMEOUT = 120_000;
20
+ const LEXICON = "@intentius/chant-lexicon-sql";
21
+ const VERSION = (JSON.parse(readFileSync(join(REPO, "lexicons", "sql", "package.json"), "utf-8")) as { version: string }).version;
22
+
23
+ function chant(cwd: string, ...argv: string[]) {
24
+ return spawnSync(process.execPath, ["--import", pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href, join(REPO, "packages/core/src/cli/main.ts"), ...argv], {
25
+ cwd,
26
+ encoding: "utf-8",
27
+ timeout: TIMEOUT,
28
+ env: { ...process.env, NO_COLOR: "1", TSX_DISABLE_CACHE: "1" },
29
+ });
30
+ }
31
+
32
+ const SQL_CONFIG = `import type { ChantConfig } from "@intentius/chant";\nexport default { lexicons: ["sql"], sql: { dialect: "postgres" } } satisfies ChantConfig;\n`;
33
+
34
+ const orders = (columns: string[]) =>
35
+ [
36
+ `import { table } from "@intentius/chant-lexicon-sql/postgres";`,
37
+ ``,
38
+ `export const orders = table\``,
39
+ ` CREATE TABLE orders (`,
40
+ ...columns.map((c, i) => ` ${c}${i < columns.length - 1 ? "," : ""}`),
41
+ ` )\`;`,
42
+ ``,
43
+ ].join("\n");
44
+
45
+ function workspace(columns: string[]): string {
46
+ const root = repo({
47
+ "chant.workspace.json": declaration(
48
+ [
49
+ { name: "db", dir: "db", kind: "chant" },
50
+ { name: "analytics", dir: "analytics", kind: "dbt", fields: { sqlMember: "db" } },
51
+ ],
52
+ { pins: [{ package: LEXICON, version: VERSION }] },
53
+ ),
54
+ "db/chant.config.ts": SQL_CONFIG,
55
+ "db/src/orders.ts": orders(columns),
56
+ "analytics/dbt_project.yml": "name: analytics\nversion: '1.0'\nprofile: analytics\n",
57
+ "analytics/models/staging/sources.yml": "version: 2\n\nsources:\n - name: shop\n schema: public\n tables:\n - name: orders\n columns:\n - name: id\n - name: customer_id\n",
58
+ ".gitignore": "node_modules\n",
59
+ });
60
+ symlinkSync(join(REPO, "node_modules"), join(root, "node_modules"), "dir");
61
+ return root;
62
+ }
63
+
64
+ describe("a dbt member through the pinned sql lexicon (#3932)", () => {
65
+ test(
66
+ "check passes while the sql member has public.orders.customer_id, and fails naming the dbt source file once it is dropped",
67
+ () => {
68
+ const ok = chant(workspace(["id bigint PRIMARY KEY", "customer_id bigint NOT NULL"]), "workspace", "check");
69
+ expect(ok.status, ok.stdout + ok.stderr).toBe(0);
70
+
71
+ const dropped = chant(workspace(["id bigint PRIMARY KEY"]), "workspace", "check");
72
+ expect(dropped.status).toBe(1);
73
+ const text = dropped.stdout + dropped.stderr;
74
+ expect(text).toContain("analytics/models/staging/sources.yml:10");
75
+ expect(text).toContain("WSP093");
76
+ expect(text).toMatch(/link to db output public\.orders\.customer_id .*does not resolve/);
77
+ },
78
+ TIMEOUT,
79
+ );
80
+
81
+ test(
82
+ "workspace graph lists the dbt member's links with their file and line",
83
+ () => {
84
+ const run = chant(workspace(["id bigint PRIMARY KEY"]), "workspace", "graph");
85
+ const doc = JSON.parse(run.stdout) as { links: { origin: string; consumer: string; producer: string; output: string; status: string; file?: string; line?: number }[] };
86
+ contract(schema).expectValid(doc);
87
+ expect(doc.links.filter((l) => l.origin === "kind-reader").map((l) => `${l.consumer} -> ${l.producer}.${l.output} ${l.status} ${l.file}:${l.line}`)).toEqual([
88
+ "analytics -> db.public.orders resolved analytics/models/staging/sources.yml:7",
89
+ "analytics -> db.public.orders.id resolved analytics/models/staging/sources.yml:9",
90
+ "analytics -> db.public.orders.customer_id missing analytics/models/staging/sources.yml:10",
91
+ ]);
92
+ },
93
+ TIMEOUT,
94
+ );
95
+ });
@@ -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
+ });