@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
@@ -0,0 +1,272 @@
1
+ /**
2
+ * The outputs a member's lexicons report about their own declarations
3
+ * (#3931), read offline for `chant workspace check`.
4
+ *
5
+ * A sql member exposes each table and each column as a workspace output
6
+ * (`public.orders`, `public.orders.customer_id`) without anyone writing
7
+ * `output(...)`. The names are the lexicon's to give, so the lexicon answers
8
+ * through its `workspaceOutputs` hook, the way a CI lexicon answers
9
+ * `graphEdges` (#3910).
10
+ *
11
+ * The check never runs member code. The member's source files are folded
12
+ * with `foldProject` under `sandbox`, which evaluates the fold subset and
13
+ * imports nothing from the project: a sql `table` template becomes the real
14
+ * entity because the tag is the lexicon's own function, called with the
15
+ * folded parts. A file that does not fold is not run either. It is handed to
16
+ * the hook as unread, and when it is one of that lexicon's files (it uses the
17
+ * lexicon's tags), the hook records a gap: the member's outputs are then
18
+ * incomplete, and a link to a name not in them is unresolved rather than
19
+ * missing.
20
+ *
21
+ * Folding is asynchronous and the checks are not, so the outputs are
22
+ * gathered before the checks run, only for the members some declared link
23
+ * targets, and only for the kinds whose outputs are read from source.
24
+ *
25
+ * Under `--at` the revision's files are not on disk to fold, so a member
26
+ * whose lexicons report outputs gets a gap instead.
27
+ */
28
+
29
+ import { join, posix } from "node:path";
30
+ import type { StaticFieldsRead } from "../config-static";
31
+ import { isDeclarable, type Declarable } from "../declarable";
32
+ import type { LexiconUnreadSource, LexiconWorkspaceOutput } from "../graph-ir";
33
+ import type { BuildRootContext, BuildRootContribution, IntrinsicDef } from "../lexicon";
34
+ import type { Declaration, ResolvedGroup } from "./declaration";
35
+ import type { KindRegistry } from "./kinds";
36
+ import { kindHandles } from "./links";
37
+ import { readKindLinks } from "./kind-link-readers";
38
+ import { memberExcludes, sourceFiles } from "./member-handles";
39
+ import { memberDirOnDisk } from "./lexicon-symbols";
40
+ import type { WorkspaceTree } from "./tree";
41
+
42
+ /** What one member's lexicons report: its outputs, and why the list may be incomplete. */
43
+ export interface MemberLexiconOutputs {
44
+ outputs: Pick<LexiconWorkspaceOutput, "name" | "previously">[];
45
+ gaps: string[];
46
+ }
47
+
48
+ /** The part of a lexicon plugin this reader uses. */
49
+ export interface OutputsPlugin {
50
+ name?: string;
51
+ intrinsics?: () => IntrinsicDef[];
52
+ buildRoots?: (ctx: BuildRootContext) => Promise<BuildRootContribution>;
53
+ workspaceOutputs?: (entities: ReadonlyMap<string, Declarable>, unread?: readonly LexiconUnreadSource[]) => { outputs: LexiconWorkspaceOutput[]; gaps?: string[] } | undefined;
54
+ }
55
+
56
+ /** Loads a lexicon plugin by name; `loadPlugin` from the CLI unless a test swaps it. */
57
+ export type OutputsPluginLoader = (name: string) => Promise<OutputsPlugin>;
58
+
59
+ export interface GatherLexiconOutputsOptions {
60
+ /** Read under `--at`: the member's files are a revision's, not on disk, so nothing is folded. */
61
+ atRevision?: boolean;
62
+ load?: OutputsPluginLoader;
63
+ }
64
+
65
+ const firstLine = (err: unknown): string => (err instanceof Error ? err.message : String(err)).split("\n")[0];
66
+
67
+ const memberDir = (dir: string) => (dir === "." ? "" : dir);
68
+
69
+ /** The `sourceDir` a member's config sets, read without running it. */
70
+ function staticSourceDir(read: StaticFieldsRead): string | undefined {
71
+ const value = read.status === "read" ? read.fields.sourceDir : undefined;
72
+ if (typeof value !== "string") return undefined;
73
+ const dir = posix.normalize(value.replace(/\\/g, "/")).replace(/\/+$/, "");
74
+ return dir === "." || dir === "" ? undefined : dir;
75
+ }
76
+
77
+ /**
78
+ * Only the files under the member's `sourceDir`, when its config sets one, as
79
+ * a build reads them (#3941). Without one, every source file of the member.
80
+ */
81
+ function withinSourceDir(files: string[], member: string, sourceDir: string | undefined): string[] {
82
+ if (!sourceDir) return files;
83
+ const prefix = `${member ? `${member}/` : ""}${sourceDir}/`;
84
+ return files.filter((f) => f.startsWith(prefix));
85
+ }
86
+
87
+ /**
88
+ * A folded file's exports, less the entities of lexicons the member doesn't
89
+ * declare (#3941). An Op is a `chant` entity, and several files exporting
90
+ * `op` would otherwise collide and leave the whole member unread, though no
91
+ * hook reads Ops. Values that are not entities (a composite) are kept.
92
+ */
93
+ function ownExports(exports: ReadonlyMap<string, unknown>, lexicons: readonly string[]): Record<string, unknown> {
94
+ const own = new Set(lexicons);
95
+ const out: Record<string, unknown> = {};
96
+ for (const [name, value] of exports) {
97
+ if (isDeclarable(value) && !own.has(value.lexicon)) continue;
98
+ out[name] = value;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * The lexicon names a member's config declares, read without running it.
105
+ * Unlike the intent walk, a config that can't be read statically is not
106
+ * loaded: the check runs no member code, so it is a gap.
107
+ */
108
+ async function staticMemberLexicons(dir: string): Promise<string[]> {
109
+ const { readLexiconDeclarationsStatically } = await import("../config-static");
110
+ const read = readLexiconDeclarationsStatically(dir);
111
+ if (read.status === "no-config") return [];
112
+ if (read.status !== "read") throw new Error(read.reason);
113
+ const { lexiconNames, registerLexiconDeclarations } = await import("../lexicon-module");
114
+ const { dirname } = await import("node:path");
115
+ registerLexiconDeclarations(read.entries, dirname(read.configPath));
116
+ return lexiconNames(read.entries);
117
+ }
118
+
119
+ /**
120
+ * The lexicon outputs of every member a declared link, or a link a kind's
121
+ * reader found (#3932), targets, keyed by
122
+ * member name. A member whose lexicons report nothing is left out.
123
+ * `tree` is the working tree under `root`.
124
+ */
125
+ export async function gatherLexiconOutputs(
126
+ root: string,
127
+ declaration: Declaration,
128
+ tree: WorkspaceTree,
129
+ groups: readonly ResolvedGroup[],
130
+ kinds: KindRegistry,
131
+ options: GatherLexiconOutputsOptions = {},
132
+ ): Promise<Map<string, MemberLexiconOutputs>> {
133
+ const out = new Map<string, MemberLexiconOutputs>();
134
+ const targets = new Set(declaration.members.flatMap((m) => m.links.filter((l) => l.member !== m.name).map((l) => l.member)));
135
+ // Links a kind's reader finds in a member's files target members too (#3932). One that names no
136
+ // producer may be any other member's, so every other member is read.
137
+ for (const [consumer, read] of readKindLinks(declaration, tree, kinds)) {
138
+ for (const link of read.links) {
139
+ if (link.member !== null) targets.add(link.member);
140
+ else for (const m of declaration.members) if (m.name !== consumer) targets.add(m.name);
141
+ }
142
+ }
143
+ for (const m of declaration.members) {
144
+ if (!targets.has(m.name)) continue;
145
+ const kind = kinds.get(m.kind);
146
+ if (!kind || kindHandles(m, kind) || tree.stat(memberDir(m.dir)) !== "dir") continue;
147
+ const read = await readMemberLexiconOutputs(root, declaration, tree, groups, m, options);
148
+ if (read) out.set(m.name, read);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /** The lexicons the files import, by the package name chant gives a lexicon. */
154
+ function importedLexicons(tree: WorkspaceTree, files: readonly string[]): string[] {
155
+ const names = new Set<string>();
156
+ for (const file of files) {
157
+ let text: string;
158
+ try {
159
+ text = tree.read(file);
160
+ } catch {
161
+ continue;
162
+ }
163
+ for (const m of text.matchAll(/from\s+["']@intentius\/chant-lexicon-([a-z0-9-]+)(?:\/[^"']*)?["']/g)) names.add(m[1]!);
164
+ }
165
+ return [...names].sort();
166
+ }
167
+
168
+ async function readMemberLexiconOutputs(
169
+ root: string,
170
+ declaration: Declaration,
171
+ tree: WorkspaceTree,
172
+ groups: readonly ResolvedGroup[],
173
+ m: Declaration["members"][number],
174
+ options: GatherLexiconOutputsOptions,
175
+ ): Promise<MemberLexiconOutputs | undefined> {
176
+ const dir = memberDirOnDisk(root, m.dir);
177
+ const { readConfigFieldsStatically } = await import("../config-static");
178
+ const files = withinSourceDir(
179
+ sourceFiles(tree, memberDir(m.dir), memberExcludes(declaration, groups, m)),
180
+ memberDir(m.dir),
181
+ staticSourceDir(readConfigFieldsStatically(dir, ["sourceDir"])),
182
+ );
183
+ let names: string[];
184
+ try {
185
+ names = await staticMemberLexicons(dir);
186
+ } catch {
187
+ // A config that can't be read statically is not run. The lexicons the
188
+ // source imports stand in for the ones it names.
189
+ names = importedLexicons(tree, files);
190
+ }
191
+ if (names.length === 0) return undefined;
192
+ const load = options.load ?? ((await import("../cli/plugins")).loadPlugin as unknown as OutputsPluginLoader);
193
+ const plugins: { name: string; plugin: OutputsPlugin }[] = [];
194
+ const gaps: string[] = [];
195
+ for (const name of names) {
196
+ try {
197
+ plugins.push({ name, plugin: await load(name) });
198
+ } catch (err) {
199
+ gaps.push(`lexicon "${name}" could not be loaded, so the outputs it reads were not (${firstLine(err)})`);
200
+ }
201
+ }
202
+ const reporting = plugins.filter((p) => typeof p.plugin.workspaceOutputs === "function");
203
+ if (reporting.length === 0) return gaps.length ? { outputs: [], gaps } : undefined;
204
+ if (options.atRevision) {
205
+ return { outputs: [], gaps: [`the outputs of lexicon ${reporting.map((p) => p.name).join(", ")} are read from the working tree's files, not a revision's`] };
206
+ }
207
+
208
+ // Fold the member's source, never run it.
209
+ const abs = (file: string) => join(root, ...file.split("/"));
210
+ const intrinsics = plugins.flatMap((p) => {
211
+ try {
212
+ return p.plugin.intrinsics?.() ?? [];
213
+ } catch {
214
+ return [];
215
+ }
216
+ });
217
+ let entities: Map<string, Declarable> = new Map();
218
+ const unread: LexiconUnreadSource[] = [];
219
+ try {
220
+ const { foldProject } = await import("../discovery/fold-import");
221
+ const { collectEntities } = await import("../discovery/collect");
222
+ const verdicts = await foldProject(files.map(abs), intrinsics, { lexicons: names, sandbox: true });
223
+ const folded: { file: string; exports: Record<string, unknown> }[] = [];
224
+ for (const file of files) {
225
+ const v = verdicts.get(abs(file));
226
+ if (v?.verdict === "fold" && v.exports) {
227
+ folded.push({ file: abs(file), exports: ownExports(v.exports, names) });
228
+ continue;
229
+ }
230
+ let text = "";
231
+ try {
232
+ text = tree.read(file);
233
+ } catch {
234
+ // An unreadable file is still unread; the hook decides whether it matters.
235
+ }
236
+ const reason = v?.reason ?? (v?.taintedBy ? `it imports ${v.taintedBy.from}, which does not fold` : "it does not fold");
237
+ unread.push({ file, text, reason });
238
+ }
239
+ entities = collectEntities(folded, dir);
240
+ } catch (err) {
241
+ return { outputs: [], gaps: [...gaps, `${m.dir}: its source could not be folded, so its lexicons' outputs were not read (${firstLine(err)})`] };
242
+ }
243
+
244
+ // A reporting lexicon's own build roots (the sql lexicon's `.sql` files) join
245
+ // the folded entities, as they join a build's. Only these lexicons' hooks
246
+ // run: they read files, and start nothing.
247
+ for (const { name, plugin } of reporting) {
248
+ if (typeof plugin.buildRoots !== "function") continue;
249
+ const read = readConfigFieldsStatically(dir, [name, "sourceDir"]);
250
+ const config = read.status === "read" ? read.fields : {};
251
+ const sourceDir = typeof config.sourceDir === "string" ? join(dir, config.sourceDir) : undefined;
252
+ try {
253
+ const contributed = await plugin.buildRoots({ projectRoot: dir, config, entities, ...(sourceDir ? { sourceDir } : {}) });
254
+ for (const [key, entity] of contributed.entities) if (!entities.has(key)) entities.set(key, entity);
255
+ } catch (err) {
256
+ gaps.push(`lexicon "${name}" could not read the member's other source files, so their declarations were not read (${firstLine(err)})`);
257
+ }
258
+ }
259
+
260
+ const outputs: MemberLexiconOutputs["outputs"] = [];
261
+ for (const { name, plugin } of reporting) {
262
+ try {
263
+ const report = plugin.workspaceOutputs!(entities, unread);
264
+ if (!report) continue;
265
+ for (const o of report.outputs) outputs.push({ name: o.name, ...(o.previously?.length ? { previously: o.previously } : {}) });
266
+ for (const g of report.gaps ?? []) gaps.push(g);
267
+ } catch (err) {
268
+ gaps.push(`lexicon "${name}" workspaceOutputs threw, so its outputs were not read (${firstLine(err)})`);
269
+ }
270
+ }
271
+ return { outputs, gaps };
272
+ }
@@ -308,6 +308,28 @@ describe("the graph's links section", () => {
308
308
  test("the linked member pairs, for the pipeline checks' environment rule", async () => {
309
309
  expect(declaredMemberLinks(decl)).toEqual([{ consumer: "web", producer: "shared" }]);
310
310
  });
311
+
312
+ test("a link to an output's previous name says what it was renamed to (#3931)", () => {
313
+ const d = parseDeclaration(
314
+ declaration([
315
+ { name: "db", dir: "db", kind: "chant" },
316
+ { name: "web", dir: "web", kind: "chant", links: [{ member: "db", output: "public.orders.customer_id" }] },
317
+ ]),
318
+ "chant.workspace.json",
319
+ );
320
+ const [row] = graphLinks(d, {
321
+ composed: ["db", "web"],
322
+ exports: [
323
+ { member: "db", name: "public.orders", node: "db/orders" },
324
+ { member: "db", name: "public.orders.buyer_id", node: "db/orders", previously: ["public.orders.customer_id"] },
325
+ ],
326
+ imports: [],
327
+ });
328
+ expect(row).toMatchObject({ status: "missing" });
329
+ expect(row!.reason).toBe(
330
+ "db has no output public.orders.customer_id; it was renamed to public.orders.buyer_id, so link to that instead; its outputs: public.orders, public.orders.buyer_id",
331
+ );
332
+ });
311
333
  });
312
334
 
313
335
  describe("the declaration's link fields", () => {
@@ -15,7 +15,11 @@
15
15
  * that parameter give way to the declared one, which is how an inferred edge
16
16
  * is promoted.
17
17
  *
18
- * Every row says how it was found (`declared` or `inferred:joinKey`), where it
18
+ * A member whose kind has a link reader (#3932, `./kind-link-readers.ts`) also
19
+ * states links in its own files, such as a dbt project's sources. Those rows
20
+ * have origin `kind-reader` and carry the file and line that state them.
21
+ *
22
+ * Every row says how it was found (`declared`, `kind-reader` or `inferred:joinKey`), where it
19
23
  * resolved (`source`, or `live` for the rows of `check --live`, #2549) and its status. A link that
20
24
  * can't be resolved is kept, with the reason.
21
25
  *
@@ -30,6 +34,7 @@ import { joinKey, joinLabel, type JoinLabel } from "../join-key";
30
34
  import type { Declaration, LinkDeclaration, Member } from "./declaration";
31
35
  import type { KindRegistry, MemberKind } from "./kinds";
32
36
  import type { MemberCollector } from "./compose-graph";
37
+ import type { KindLinkRead, KindReadLink } from "./kind-link-readers";
33
38
 
34
39
  /**
35
40
  * The link kinds chant knows. Closed: a link of any other kind fails closed.
@@ -46,14 +51,20 @@ export const TELEMETRY_LINK_KIND = "telemetry";
46
51
  /** The OTLP protocols a telemetry link may state, as `OTEL_EXPORTER_OTLP_PROTOCOL` names them. */
47
52
  export const TELEMETRY_PROTOCOLS = ["grpc", "http/protobuf", "http/json"] as const;
48
53
 
49
- /** How a row was found: written down, or inferred by the named rule. */
50
- export type LinkOrigin = "declared" | "inferred:joinKey";
54
+ /**
55
+ * How a row was found: written down in the declaration, read from the
56
+ * member's own files by its kind's link reader (#3932), or inferred by the
57
+ * named rule.
58
+ */
59
+ export type LinkOrigin = "declared" | "kind-reader" | "inferred:joinKey";
51
60
 
52
61
  /** One output or parameter of a member, as a join sees it. */
53
62
  export interface JoinHandle {
54
63
  name: string;
55
64
  /** The node that holds it, when the reader knows one (a graph IR id). */
56
65
  node?: string;
66
+ /** Names it had before, from a rename hint in the producer's source (#3931). */
67
+ previously?: readonly string[];
57
68
  }
58
69
 
59
70
  /** What a member offers links, and what it reads. */
@@ -80,6 +91,10 @@ interface RowBase {
80
91
  resolves: "source" | "live";
81
92
  /** Why the row is not `resolved`, or null. */
82
93
  reason: string | null;
94
+ /** A `kind-reader` row (#3932): the reader, and the file (from the workspace root) and line that state the link. */
95
+ reader?: string;
96
+ file?: string;
97
+ line?: number;
83
98
  }
84
99
 
85
100
  /** A declared link, or an inferred join with one producer. */
@@ -113,7 +128,11 @@ export interface TelemetryTargets {
113
128
  composed: ReadonlySet<string>;
114
129
  }
115
130
 
116
- /** A parameter that joins outputs of two or more producers (or two outputs of one). */
131
+ /**
132
+ * A parameter that joins outputs of two or more producers (or two outputs of
133
+ * one). For a `kind-reader` row whose file names no producer, `input` is the
134
+ * output it reads, and the candidates are the members that could produce it.
135
+ */
117
136
  export interface AmbiguousRow extends RowBase {
118
137
  status: "ambiguous";
119
138
  input: string;
@@ -170,9 +189,16 @@ function nearNames(name: string, outputs: JoinHandle[]): string[] {
170
189
  * Resolve every declared link and infer the joins nobody wrote down.
171
190
  * `handles` holds one entry per member whose handles were read; a member
172
191
  * missing from it is unresolved. Declared rows come first, in declaration
173
- * order; inferred and ambiguous rows follow, sorted by consumer and parameter.
192
+ * order; then the rows each member's kind reader found (`readLinks`, #3932),
193
+ * in member and file order; inferred and ambiguous rows follow, sorted by
194
+ * consumer and parameter.
174
195
  */
175
- export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<string, MemberHandles>, telemetry?: TelemetryTargets): LinkTableRow[] {
196
+ export function resolveLinks(
197
+ declaration: Declaration,
198
+ handles: ReadonlyMap<string, MemberHandles>,
199
+ telemetry?: TelemetryTargets,
200
+ readLinks?: ReadonlyMap<string, KindLinkRead>,
201
+ ): LinkTableRow[] {
176
202
  const rows: LinkTableRow[] = [];
177
203
  const declaredTargets = new Map<string, Set<string>>();
178
204
 
@@ -215,30 +241,18 @@ export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<stri
215
241
  row.reason = `protocol applies to telemetry links; this link has kind ${kind}`;
216
242
  continue;
217
243
  }
218
- const producer = handles.get(link.member);
219
- const found = producer?.outputs.find((o) => o.name === link.output);
220
- if (found) {
221
- if (found.node) row.to = found.node;
222
- continue;
223
- }
224
- if (!producer) {
225
- row.status = "unresolved";
226
- row.reason = `the outputs of ${link.member} were not read`;
227
- } else if (producer.exposesNone) {
228
- row.status = "missing";
229
- row.reason = producer.why ?? `${link.member} exposes no outputs`;
230
- } else if (!producer.complete) {
231
- row.status = "unresolved";
232
- row.reason = `${link.output} was not found in ${link.member}, whose outputs could not all be read in source${producer.why ? ` (${producer.why})` : ""}`;
233
- } else {
234
- const near = nearNames(link.output, producer.outputs);
235
- const known = producer.outputs.map((o) => o.name);
236
- row.status = "missing";
237
- row.reason =
238
- `${link.member} has no output ${link.output}` +
239
- (near.length ? `; did you mean ${near.join(" or ")}? Links match exactly` : "") +
240
- (known.length ? `; its outputs: ${[...new Set(known)].sort(cmp).join(", ")}` : "; it exposes no outputs");
241
- }
244
+ resolveOutput(row, handles);
245
+ }
246
+ }
247
+
248
+ // Links a kind's reader found in the member's own files (#3932).
249
+ for (const consumer of declaration.members) {
250
+ const read = readLinks?.get(consumer.name);
251
+ if (!read) continue;
252
+ for (const link of read.links) {
253
+ const row = readerRow(declaration, consumer, read, link, handles);
254
+ rows.push(row);
255
+ if (row.status !== "ambiguous") (declaredTargets.get(consumer.name) ?? declaredTargets.set(consumer.name, new Set()).get(consumer.name)!).add(`${row.producer}\0${row.output}`);
242
256
  }
243
257
  }
244
258
 
@@ -297,6 +311,85 @@ export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<stri
297
311
  return [...rows, ...inferred];
298
312
  }
299
313
 
314
+ /** Match a declared or reader's output row against its producer's handles, setting its status and reason. */
315
+ function resolveOutput(row: LinkRow, handles: ReadonlyMap<string, MemberHandles>): void {
316
+ const producer = handles.get(row.producer);
317
+ const found = producer?.outputs.find((o) => o.name === row.output);
318
+ if (found) {
319
+ if (found.node) row.to = found.node;
320
+ return;
321
+ }
322
+ if (!producer) {
323
+ row.status = "unresolved";
324
+ row.reason = `the outputs of ${row.producer} were not read`;
325
+ } else if (producer.exposesNone) {
326
+ row.status = "missing";
327
+ row.reason = producer.why ?? `${row.producer} exposes no outputs`;
328
+ } else if (!producer.complete) {
329
+ row.status = "unresolved";
330
+ row.reason = `${row.output} was not found in ${row.producer}, whose outputs could not all be read in source${producer.why ? ` (${producer.why})` : ""}`;
331
+ } else {
332
+ const renamed = [...new Set(producer.outputs.filter((o) => o.previously?.includes(row.output)).map((o) => o.name))].sort(cmp);
333
+ const near = renamed.length ? [] : nearNames(row.output, producer.outputs);
334
+ const known = producer.outputs.map((o) => o.name);
335
+ row.status = "missing";
336
+ row.reason =
337
+ `${row.producer} has no output ${row.output}` +
338
+ (renamed.length ? `; it was renamed to ${renamed.join(" or ")}, so link to that instead` : "") +
339
+ (near.length ? `; did you mean ${near.join(" or ")}? Links match exactly` : "") +
340
+ (known.length ? `; its outputs: ${[...new Set(known)].sort(cmp).join(", ")}` : "; it exposes no outputs");
341
+ }
342
+ }
343
+
344
+ /**
345
+ * The row for one link a kind's reader found (#3932). A link whose file names
346
+ * its producer resolves like a declared one. One that doesn't is given the
347
+ * producer that exposes its anchor (a dbt column's table); when none does,
348
+ * and exactly one other member exposes outputs at all, that member, so a
349
+ * dropped table is missing from the only place it could be. Otherwise the
350
+ * row is ambiguous, and the message names the field that settles it.
351
+ */
352
+ function readerRow(declaration: Declaration, consumer: Member, read: KindLinkRead, link: KindReadLink, handles: ReadonlyMap<string, MemberHandles>): LinkTableRow {
353
+ const base = { consumer: consumer.name, kind: DEFAULT_LINK_KIND, origin: "kind-reader" as const, resolves: "source" as const, reader: read.reader, file: link.file, line: link.line };
354
+ let producer = link.member;
355
+ if (producer === null) {
356
+ const anchor = link.anchor ?? link.output;
357
+ const others = declaration.members.filter((m) => m.name !== consumer.name);
358
+ const exposing = others.filter((m) => handles.get(m.name)?.outputs.some((o) => o.name === anchor));
359
+ const possible = others.filter((m) => {
360
+ const h = handles.get(m.name);
361
+ return h !== undefined && !h.exposesNone;
362
+ });
363
+ if (exposing.length === 1) producer = exposing[0].name;
364
+ else if (exposing.length === 0 && possible.length === 1) producer = possible[0].name;
365
+ else {
366
+ const candidates = (exposing.length > 0 ? exposing : possible).map((m) => ({ producer: m.name, output: link.output, label: "exact" as const }));
367
+ const field = read.producerField ? `; set fields.${read.producerField} on ${consumer.name} to name it` : "";
368
+ return {
369
+ ...base,
370
+ status: "ambiguous",
371
+ input: link.output,
372
+ candidates,
373
+ reason:
374
+ exposing.length > 1
375
+ ? `${link.file}:${link.line} reads ${anchor}, which ${exposing.map((m) => m.name).join(" and ")} all expose${field}`
376
+ : candidates.length > 0
377
+ ? `${link.file}:${link.line} reads ${anchor}, which no member exposes, and ${consumer.name} does not say which of ${candidates.map((c) => c.producer).join(", ")} produces it${field}`
378
+ : `${link.file}:${link.line} reads ${anchor}, and no other member exposes outputs`,
379
+ };
380
+ }
381
+ }
382
+ const row: LinkRow = { ...base, producer, output: link.output, label: "exact", input: null, status: "resolved", reason: null };
383
+ const problem = linkTargetProblem(declaration, consumer, { member: producer } as LinkDeclaration);
384
+ if (problem) {
385
+ row.status = "invalid";
386
+ row.reason = read.producerField ? `fields.${read.producerField}: ${problem}` : problem;
387
+ return row;
388
+ }
389
+ resolveOutput(row, handles);
390
+ return row;
391
+ }
392
+
300
393
  /**
301
394
  * Resolve a telemetry link against the producer's collector (#2558): the
302
395
  * target exists (presence), and when the link states a protocol, the target
@@ -369,7 +462,7 @@ export interface ComposedHandles {
369
462
  composed: readonly string[];
370
463
  /** The collectors those members report (#2558); a telemetry link resolves against them. */
371
464
  collectors?: readonly MemberCollector[];
372
- exports: readonly { member: string; name: string; node?: string }[];
465
+ exports: readonly { member: string; name: string; node?: string; previously?: readonly string[] }[];
373
466
  imports: readonly { member: string; name: string; node: string }[];
374
467
  }
375
468
 
@@ -384,7 +477,9 @@ export function irMemberHandles(graph: ComposedHandles): Map<string, MemberHandl
384
477
  for (const member of graph.composed) {
385
478
  out.set(member, {
386
479
  member,
387
- outputs: graph.exports.filter((e) => e.member === member).map((e) => ({ name: e.name, ...(e.node ? { node: e.node } : {}) })),
480
+ outputs: graph.exports
481
+ .filter((e) => e.member === member)
482
+ .map((e) => ({ name: e.name, ...(e.node ? { node: e.node } : {}), ...(e.previously?.length ? { previously: e.previously } : {}) })),
388
483
  inputs: graph.imports.filter((i) => i.member === member).map((i) => ({ name: i.name, node: i.node })),
389
484
  complete: true,
390
485
  why: null,
@@ -401,7 +496,7 @@ export function irMemberHandles(graph: ComposedHandles): Map<string, MemberHandl
401
496
  * With `kinds`, a member that isn't composed but whose kind lists its outputs
402
497
  * (`other`, kinds from a package) is a link target too.
403
498
  */
404
- export function graphLinks(declaration: Declaration, graph: ComposedHandles, kinds?: KindRegistry): LinkTableRow[] {
499
+ export function graphLinks(declaration: Declaration, graph: ComposedHandles, kinds?: KindRegistry, readLinks?: ReadonlyMap<string, KindLinkRead>): LinkTableRow[] {
405
500
  const handles = irMemberHandles(graph);
406
501
  for (const m of declaration.members) {
407
502
  const kind = kinds?.get(m.kind);
@@ -413,7 +508,7 @@ export function graphLinks(declaration: Declaration, graph: ComposedHandles, kin
413
508
  collectors: new Map((graph.collectors ?? []).map((c) => [c.member, c])),
414
509
  composed: new Set(graph.composed),
415
510
  };
416
- return resolveLinks(declaration, handles, telemetry).map((row) => {
511
+ return resolveLinks(declaration, handles, telemetry, readLinks).map((row) => {
417
512
  if (row.status === "ambiguous" || row.origin !== "declared") return row;
418
513
  // The declaration's pointer means nothing to a graph reader.
419
514
  const { pointer: _pointer, ...rest } = row;
@@ -25,6 +25,7 @@ import { isInside, type Declaration, type ResolvedGroup } from "./declaration";
25
25
  import type { KindRegistry } from "./kinds";
26
26
  import { kindHandles, type MemberHandles } from "./links";
27
27
  import { joinPath, skippedDir, type WorkspaceTree } from "./tree";
28
+ import type { MemberLexiconOutputs } from "./lexicon-outputs";
28
29
 
29
30
  const memberDir = (dir: string) => (dir === "." ? "" : dir);
30
31
 
@@ -60,7 +61,7 @@ function isSourceFile(name: string): boolean {
60
61
  * directories (other members, group matches), `node_modules`, dot
61
62
  * directories and build output.
62
63
  */
63
- function sourceFiles(tree: WorkspaceTree, dir: string, exclude: ReadonlySet<string>): string[] {
64
+ export function sourceFiles(tree: WorkspaceTree, dir: string, exclude: ReadonlySet<string>): string[] {
64
65
  const out: string[] = [];
65
66
  const walk = (at: string) => {
66
67
  for (const e of tree.list(at) ?? []) {
@@ -188,18 +189,35 @@ export function readSourceHandles(tree: WorkspaceTree, dir: string, exclude: Rea
188
189
  return out;
189
190
  }
190
191
 
192
+ /**
193
+ * The directories inside member `m` that are not its source: other members'
194
+ * directories and example-group matches nested in it.
195
+ */
196
+ export function memberExcludes(declaration: Declaration, groups: readonly ResolvedGroup[], m: Declaration["members"][number]): Set<string> {
197
+ const exclude = new Set<string>();
198
+ for (const o of declaration.members) if (o !== m && o.dir !== "." && isInside(o.dir, m.dir)) exclude.add(o.dir);
199
+ for (const g of groups) for (const dir of g.matches) if (isInside(dir, m.dir)) exclude.add(dir);
200
+ return exclude;
201
+ }
202
+
191
203
  /**
192
204
  * Every member's handles, read in source for `chant workspace check`. A
193
205
  * `chant` member's outputs and parameters are parsed from its TypeScript
194
206
  * without running it; other kinds expose what the kind and the entry list.
195
207
  * A member whose directory is missing or whose kind is unknown is left out,
196
208
  * so links to it are unresolved.
209
+ *
210
+ * `lexiconOutputs` adds what the member's lexicons report about their own
211
+ * declarations (#3931, `./lexicon-outputs.ts`), gathered beforehand because
212
+ * reading them folds the member's files, which is asynchronous. Its gaps
213
+ * count as the member's.
197
214
  */
198
215
  export function sourceMemberHandles(
199
216
  declaration: Declaration,
200
217
  tree: WorkspaceTree,
201
218
  groups: readonly ResolvedGroup[],
202
219
  kinds: KindRegistry,
220
+ lexiconOutputs?: ReadonlyMap<string, MemberLexiconOutputs>,
203
221
  ): Map<string, MemberHandles> {
204
222
  const out = new Map<string, MemberHandles>();
205
223
  for (const m of declaration.members) {
@@ -210,16 +228,18 @@ export function sourceMemberHandles(
210
228
  out.set(m.name, listed);
211
229
  } else {
212
230
  // Other members' directories and group matches inside this one are not its source.
213
- const exclude = new Set<string>();
214
- for (const o of declaration.members) if (o !== m && o.dir !== "." && isInside(o.dir, m.dir)) exclude.add(o.dir);
215
- for (const g of groups) for (const dir of g.matches) if (isInside(dir, m.dir)) exclude.add(dir);
216
- const read = readSourceHandles(tree, memberDir(m.dir), exclude);
231
+ const read = readSourceHandles(tree, memberDir(m.dir), memberExcludes(declaration, groups, m));
232
+ const lexicon = lexiconOutputs?.get(m.name);
233
+ const gaps = [...read.gaps, ...(lexicon?.gaps ?? [])];
217
234
  out.set(m.name, {
218
235
  member: m.name,
219
- outputs: read.outputs.map((o) => ({ name: o.name })),
236
+ outputs: [
237
+ ...read.outputs.map((o) => ({ name: o.name })),
238
+ ...(lexicon?.outputs ?? []).map((o) => ({ name: o.name, ...(o.previously?.length ? { previously: o.previously } : {}) })),
239
+ ],
220
240
  inputs: read.inputs.map((i) => ({ name: i.name })),
221
- complete: read.gaps.length === 0,
222
- why: read.gaps.length === 0 ? null : read.gaps.length === 1 ? read.gaps[0] : `${read.gaps[0]}, and ${read.gaps.length - 1} more`,
241
+ complete: gaps.length === 0,
242
+ why: gaps.length === 0 ? null : gaps.length === 1 ? gaps[0] : `${gaps[0]}, and ${gaps.length - 1} more`,
223
243
  exposesNone: false,
224
244
  });
225
245
  }