@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.
- package/dist/audit/rules-doc.js +1 -1
- package/dist/cli/handlers/graph.js +24 -1
- package/dist/codegen/docs-pages.d.ts +1 -0
- package/dist/codegen/docs-pages.d.ts.map +1 -1
- package/dist/codegen/docs-pages.js +1 -0
- package/dist/codegen/docs-sections.d.ts +2 -0
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/codegen/docs-sections.js +8 -0
- package/dist/codegen/docs-types.d.ts +7 -0
- package/dist/codegen/docs-types.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/docs.js +20 -0
- package/dist/fold/subset.d.ts +3 -1
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/fold/subset.js +3 -1
- package/dist/graph-ir.d.ts +43 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +27 -0
- package/dist/lexicon.d.ts +25 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lint/rules/evl004-spread-non-const.d.ts.map +1 -1
- package/dist/lint/rules/evl004-spread-non-const.js +19 -1
- package/dist/wave-runtime.mjs +222 -206
- package/dist/workspace/checks/links.d.ts +5 -0
- package/dist/workspace/checks/links.d.ts.map +1 -1
- package/dist/workspace/checks/links.js +54 -8
- package/dist/workspace/checks.d.ts +4 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +6 -2
- package/dist/workspace/compose-graph.d.ts +4 -1
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +3 -2
- package/dist/workspace/dbt-project-reader.d.ts +36 -0
- package/dist/workspace/dbt-project-reader.d.ts.map +1 -0
- package/dist/workspace/dbt-project-reader.js +290 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/graph-cli.js +3 -1
- package/dist/workspace/graph.schema.json +41 -3
- package/dist/workspace/intent.js +7 -2
- package/dist/workspace/kind-link-readers.d.ts +84 -0
- package/dist/workspace/kind-link-readers.d.ts.map +1 -0
- package/dist/workspace/kind-link-readers.js +53 -0
- package/dist/workspace/kinds.d.ts +15 -0
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +11 -0
- package/dist/workspace/lexicon-outputs.d.ts +63 -0
- package/dist/workspace/lexicon-outputs.d.ts.map +1 -0
- package/dist/workspace/lexicon-outputs.js +241 -0
- package/dist/workspace/links.d.ts +29 -7
- package/dist/workspace/links.d.ts.map +1 -1
- package/dist/workspace/links.js +109 -34
- package/dist/workspace/member-handles.d.ts +18 -1
- package/dist/workspace/member-handles.d.ts.map +1 -1
- package/dist/workspace/member-handles.js +31 -14
- package/dist/workspace/records-checkout.d.ts +11 -1
- package/dist/workspace/records-checkout.d.ts.map +1 -1
- package/dist/workspace/records-checkout.js +38 -2
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +3 -2
- package/dist/workspace/workspace-kinds.schema.json +5 -0
- package/package.json +1 -1
- package/src/audit/rules-doc.ts +1 -1
- package/src/cli/handlers/graph.ts +22 -1
- package/src/codegen/docs-pages.ts +1 -0
- package/src/codegen/docs-sections.ts +8 -0
- package/src/codegen/docs-site/AgentPrompt.astro +153 -0
- package/src/codegen/docs-types.ts +7 -0
- package/src/codegen/docs.ts +27 -0
- package/src/fold/subset.ts +3 -1
- package/src/graph-ir.test.ts +23 -1
- package/src/graph-ir.ts +65 -0
- package/src/lexicon.ts +26 -1
- package/src/lint/rules/evl004-spread-non-const.test.ts +18 -0
- package/src/lint/rules/evl004-spread-non-const.ts +16 -1
- package/src/wave-loop-golden.test.ts +3 -0
- package/src/workspace/checks/links.ts +57 -8
- package/src/workspace/checks.e2e.test.ts +1 -1
- package/src/workspace/checks.ts +9 -2
- package/src/workspace/compose-graph.ts +5 -3
- package/src/workspace/dbt-kind.e2e.test.ts +95 -0
- package/src/workspace/dbt-kind.test.ts +283 -0
- package/src/workspace/dbt-project-reader.ts +291 -0
- package/src/workspace/graph-cli.ts +3 -1
- package/src/workspace/graph.schema.json +41 -3
- package/src/workspace/intent.ts +7 -2
- package/src/workspace/kind-link-readers.ts +109 -0
- package/src/workspace/kinds.ts +21 -1
- package/src/workspace/lexicon-outputs.ts +272 -0
- package/src/workspace/links.test.ts +22 -0
- package/src/workspace/links.ts +129 -34
- package/src/workspace/member-handles.ts +28 -8
- package/src/workspace/records-checkout.ts +36 -2
- package/src/workspace/records-uncommitted.test.ts +5 -1
- package/src/workspace/status.ts +3 -2
- 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", () => {
|
package/src/workspace/links.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
50
|
-
|
|
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
|
-
/**
|
|
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;
|
|
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(
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
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:
|
|
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:
|
|
222
|
-
why:
|
|
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
|
}
|