@duckcodeailabs/dql-core 1.6.1 → 1.6.2
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/ast/index.d.ts +1 -1
- package/dist/ast/index.d.ts.map +1 -1
- package/dist/ast/index.js.map +1 -1
- package/dist/ast/nodes.d.ts +49 -1
- package/dist/ast/nodes.d.ts.map +1 -1
- package/dist/ast/nodes.js +3 -0
- package/dist/ast/nodes.js.map +1 -1
- package/dist/ast/printer.d.ts.map +1 -1
- package/dist/ast/printer.js +56 -0
- package/dist/ast/printer.js.map +1 -1
- package/dist/ast/visitor.d.ts +5 -1
- package/dist/ast/visitor.d.ts.map +1 -1
- package/dist/ast/visitor.js +16 -0
- package/dist/ast/visitor.js.map +1 -1
- package/dist/formatter/formatter.d.ts.map +1 -1
- package/dist/formatter/formatter.js +111 -0
- package/dist/formatter/formatter.js.map +1 -1
- package/dist/lexer/token.d.ts +4 -0
- package/dist/lexer/token.d.ts.map +1 -1
- package/dist/lexer/token.js +8 -0
- package/dist/lexer/token.js.map +1 -1
- package/dist/lineage/builder.d.ts +29 -0
- package/dist/lineage/builder.d.ts.map +1 -1
- package/dist/lineage/builder.js +88 -4
- package/dist/lineage/builder.js.map +1 -1
- package/dist/lineage/index.d.ts +2 -2
- package/dist/lineage/index.d.ts.map +1 -1
- package/dist/lineage/index.js +1 -1
- package/dist/lineage/index.js.map +1 -1
- package/dist/lineage/lineage-graph.d.ts +2 -2
- package/dist/lineage/lineage-graph.d.ts.map +1 -1
- package/dist/lineage/lineage-graph.js +2 -0
- package/dist/lineage/lineage-graph.js.map +1 -1
- package/dist/lineage/query.d.ts +64 -0
- package/dist/lineage/query.d.ts.map +1 -1
- package/dist/lineage/query.js +260 -0
- package/dist/lineage/query.js.map +1 -1
- package/dist/manifest/builder.d.ts +8 -0
- package/dist/manifest/builder.d.ts.map +1 -1
- package/dist/manifest/builder.js +479 -77
- package/dist/manifest/builder.js.map +1 -1
- package/dist/manifest/index.d.ts +2 -2
- package/dist/manifest/index.d.ts.map +1 -1
- package/dist/manifest/index.js +1 -1
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/types.d.ts +59 -0
- package/dist/manifest/types.d.ts.map +1 -1
- package/dist/parser/parser.d.ts +4 -0
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +308 -3
- package/dist/parser/parser.js.map +1 -1
- package/dist/semantic/analyzer.d.ts +2 -0
- package/dist/semantic/analyzer.d.ts.map +1 -1
- package/dist/semantic/analyzer.js +28 -0
- package/dist/semantic/analyzer.js.map +1 -1
- package/package.json +1 -1
package/dist/manifest/builder.js
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
9
9
|
import { join, extname, relative } from 'node:path';
|
|
10
10
|
import { Parser } from '../parser/index.js';
|
|
11
|
+
import { analyze } from '../semantic/index.js';
|
|
11
12
|
import { extractTablesFromSql } from '../lineage/sql-parser.js';
|
|
12
13
|
import { extractColumnLineage } from '../lineage/column-lineage.js';
|
|
13
14
|
import { buildLineageGraph } from '../lineage/builder.js';
|
|
14
15
|
import { detectDomainFlows, getDomainTrustOverview } from '../lineage/domain-lineage.js';
|
|
15
16
|
import { loadSemanticLayerFromDir } from '../semantic/index.js';
|
|
17
|
+
import { DataLexContractRegistry } from '../contracts/index.js';
|
|
16
18
|
import { loadAppDocument, findAppDocuments, findDashboardsForApp, loadDashboardDocument, extractDashboardBlockRefs, appFolderRelPath, } from '../apps/index.js';
|
|
17
19
|
/**
|
|
18
20
|
* Enumerate every file whose contents could change the manifest output.
|
|
@@ -30,11 +32,25 @@ export function collectInputFiles(options) {
|
|
|
30
32
|
if (existsSync(configPath))
|
|
31
33
|
files.add(configPath);
|
|
32
34
|
const config = loadProjectConfig(projectRoot);
|
|
35
|
+
const datalexManifestPath = resolveDataLexManifestPath(projectRoot, options.datalexManifestPath, config);
|
|
36
|
+
if (datalexManifestPath && existsSync(datalexManifestPath)) {
|
|
37
|
+
files.add(datalexManifestPath);
|
|
38
|
+
}
|
|
33
39
|
const blockDirs = ['blocks', 'dashboards', 'workbooks', ...(options.extraBlockDirs ?? [])];
|
|
34
40
|
for (const dir of blockDirs) {
|
|
35
41
|
for (const f of scanFilesRecursive(join(projectRoot, dir), ['.dql']))
|
|
36
42
|
files.add(f);
|
|
37
43
|
}
|
|
44
|
+
const businessViewDirs = ['blocks', 'business-views', 'dashboards', 'workbooks', ...(options.extraBlockDirs ?? [])];
|
|
45
|
+
for (const dir of businessViewDirs) {
|
|
46
|
+
for (const f of scanFilesRecursive(join(projectRoot, dir), ['.dql']))
|
|
47
|
+
files.add(f);
|
|
48
|
+
}
|
|
49
|
+
const termDirs = ['terms', 'blocks', 'business-views', 'dashboards', 'workbooks', ...(options.extraBlockDirs ?? [])];
|
|
50
|
+
for (const dir of termDirs) {
|
|
51
|
+
for (const f of scanFilesRecursive(join(projectRoot, dir), ['.dql']))
|
|
52
|
+
files.add(f);
|
|
53
|
+
}
|
|
38
54
|
const notebookDirs = ['notebooks', 'blocks', 'dashboards', 'workbooks', ...(options.extraNotebookDirs ?? [])];
|
|
39
55
|
for (const dir of notebookDirs) {
|
|
40
56
|
for (const f of scanFilesRecursive(join(projectRoot, dir), ['.dqlnb']))
|
|
@@ -64,18 +80,38 @@ export function buildManifest(options) {
|
|
|
64
80
|
// Load project config
|
|
65
81
|
const config = loadProjectConfig(projectRoot);
|
|
66
82
|
const projectName = config.project ?? 'dql-project';
|
|
83
|
+
const datalexManifestPath = resolveDataLexManifestPath(projectRoot, options.datalexManifestPath, config);
|
|
84
|
+
const datalexRegistry = datalexManifestPath
|
|
85
|
+
? new DataLexContractRegistry({ manifestPath: datalexManifestPath })
|
|
86
|
+
: undefined;
|
|
87
|
+
for (const message of datalexRegistry?.loadDiagnostics() ?? []) {
|
|
88
|
+
diagnostics.push({
|
|
89
|
+
kind: 'config',
|
|
90
|
+
filePath: datalexManifestPath ? relative(projectRoot, datalexManifestPath) : undefined,
|
|
91
|
+
severity: 'warning',
|
|
92
|
+
message,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
67
95
|
// Scan blocks
|
|
68
96
|
const blockDirs = ['blocks', 'dashboards', 'workbooks', ...(options.extraBlockDirs ?? [])];
|
|
69
|
-
const blocks = scanBlocks(projectRoot, blockDirs, diagnostics);
|
|
97
|
+
const blocks = scanBlocks(projectRoot, blockDirs, diagnostics, datalexRegistry);
|
|
98
|
+
// Scan business composition views
|
|
99
|
+
const businessViewDirs = ['blocks', 'business-views', 'dashboards', 'workbooks', ...(options.extraBlockDirs ?? [])];
|
|
100
|
+
const businessViews = scanBusinessViews(projectRoot, businessViewDirs, diagnostics);
|
|
101
|
+
// Scan business glossary terms
|
|
102
|
+
const termDirs = ['terms', 'blocks', 'business-views', 'dashboards', 'workbooks', ...(options.extraBlockDirs ?? [])];
|
|
103
|
+
const terms = scanTerms(projectRoot, termDirs, diagnostics);
|
|
70
104
|
// Scan notebooks
|
|
71
105
|
const notebookDirs = ['notebooks', 'blocks', 'dashboards', 'workbooks', ...(options.extraNotebookDirs ?? [])];
|
|
72
|
-
const notebooks = scanNotebooks(projectRoot, notebookDirs, diagnostics);
|
|
106
|
+
const notebooks = scanNotebooks(projectRoot, notebookDirs, diagnostics, datalexRegistry);
|
|
73
107
|
// Extract blocks declared inside notebook DQL cells
|
|
74
|
-
const notebookBlocks = extractNotebookBlocks(notebooks
|
|
108
|
+
const notebookBlocks = extractNotebookBlocks(notebooks);
|
|
75
109
|
for (const [name, block] of Object.entries(notebookBlocks)) {
|
|
76
110
|
if (!blocks[name])
|
|
77
111
|
blocks[name] = block;
|
|
78
112
|
}
|
|
113
|
+
validateBusinessViews(businessViews, blocks, diagnostics);
|
|
114
|
+
validateTermRefs(terms, blocks, businessViews, diagnostics);
|
|
79
115
|
// Load semantic layer
|
|
80
116
|
const semanticDir = resolveSemanticPath(projectRoot, config);
|
|
81
117
|
const { metrics, dimensions } = loadSemanticDefinitions(projectRoot, semanticDir);
|
|
@@ -113,7 +149,7 @@ export function buildManifest(options) {
|
|
|
113
149
|
// because dashboard refs are resolved against the block path → name map.
|
|
114
150
|
const { apps, dashboards } = scanAppsAndDashboards(projectRoot, blocks, diagnostics);
|
|
115
151
|
// Build lineage
|
|
116
|
-
const lineage = buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport, apps, dashboards);
|
|
152
|
+
const lineage = buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport, apps, dashboards, businessViews, terms);
|
|
117
153
|
return {
|
|
118
154
|
manifestVersion: 2,
|
|
119
155
|
dqlVersion,
|
|
@@ -121,6 +157,8 @@ export function buildManifest(options) {
|
|
|
121
157
|
project: projectName,
|
|
122
158
|
projectRoot,
|
|
123
159
|
blocks,
|
|
160
|
+
businessViews,
|
|
161
|
+
terms,
|
|
124
162
|
notebooks,
|
|
125
163
|
metrics,
|
|
126
164
|
dimensions,
|
|
@@ -172,6 +210,19 @@ export function resolveDbtManifestPath(projectRoot, explicit) {
|
|
|
172
210
|
return fallback;
|
|
173
211
|
return null;
|
|
174
212
|
}
|
|
213
|
+
export function resolveDataLexManifestPath(projectRoot, explicit, config = loadProjectConfig(projectRoot)) {
|
|
214
|
+
if (explicit) {
|
|
215
|
+
return isAbsPath(explicit) ? explicit : join(projectRoot, explicit);
|
|
216
|
+
}
|
|
217
|
+
const configured = config.datalex?.manifestPath;
|
|
218
|
+
if (configured) {
|
|
219
|
+
return isAbsPath(configured) ? configured : join(projectRoot, configured);
|
|
220
|
+
}
|
|
221
|
+
const fallback = join(projectRoot, 'datalex-manifest.json');
|
|
222
|
+
if (existsSync(fallback))
|
|
223
|
+
return fallback;
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
175
226
|
function isAbsPath(p) {
|
|
176
227
|
return p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p);
|
|
177
228
|
}
|
|
@@ -202,7 +253,7 @@ function scanFilesRecursive(dir, extensions) {
|
|
|
202
253
|
return results;
|
|
203
254
|
}
|
|
204
255
|
// ---- Block Scanning ----
|
|
205
|
-
function scanBlocks(projectRoot, dirs, diagnostics) {
|
|
256
|
+
function scanBlocks(projectRoot, dirs, diagnostics, datalexRegistry) {
|
|
206
257
|
const blocks = {};
|
|
207
258
|
for (const dir of dirs) {
|
|
208
259
|
const dirPath = join(projectRoot, dir);
|
|
@@ -213,67 +264,12 @@ function scanBlocks(projectRoot, dirs, diagnostics) {
|
|
|
213
264
|
const source = readFileSync(filePath, 'utf-8');
|
|
214
265
|
const parser = new Parser(source, relPath);
|
|
215
266
|
const ast = parser.parse();
|
|
267
|
+
collectContractDiagnostics(ast, relPath, diagnostics, datalexRegistry);
|
|
216
268
|
for (const stmt of ast.statements) {
|
|
217
269
|
const block = stmt;
|
|
218
270
|
if (block.kind !== 'BlockDecl')
|
|
219
271
|
continue;
|
|
220
|
-
|
|
221
|
-
const parseResult = extractTablesFromSql(sql);
|
|
222
|
-
const columnLineage = sql ? extractColumnLineage(sql) : null;
|
|
223
|
-
const domain = extractProp(block, 'domain');
|
|
224
|
-
const owner = extractProp(block, 'owner');
|
|
225
|
-
const description = extractProp(block, 'description');
|
|
226
|
-
const tags = extractTags(block);
|
|
227
|
-
const tests = extractTests(block);
|
|
228
|
-
const agent = extractAgentMetadata(block);
|
|
229
|
-
blocks[block.name] = {
|
|
230
|
-
name: block.name,
|
|
231
|
-
filePath: relPath,
|
|
232
|
-
domain,
|
|
233
|
-
owner,
|
|
234
|
-
status: extractProp(block, 'status'),
|
|
235
|
-
blockType: block.blockType,
|
|
236
|
-
sql,
|
|
237
|
-
rawTableRefs: parseResult.tables,
|
|
238
|
-
tableDependencies: parseResult.tables.map(normalizeTableName),
|
|
239
|
-
refDependencies: parseResult.refs,
|
|
240
|
-
metricRefs: parseResult.metricRefs,
|
|
241
|
-
dimensionRefs: parseResult.dimensionRefs,
|
|
242
|
-
dimensionsRef: block.dimensionsRef,
|
|
243
|
-
allDependencies: [
|
|
244
|
-
...parseResult.refs,
|
|
245
|
-
...parseResult.tables.map(normalizeTableName),
|
|
246
|
-
...parseResult.metricRefs.map((m) => `@metric(${m})`),
|
|
247
|
-
...parseResult.dimensionRefs.map((d) => `@dim(${d})`),
|
|
248
|
-
...((block.metricsRef ?? (block.metricRef ? [block.metricRef] : [])).map((m) => `@metric(${m})`)),
|
|
249
|
-
...((block.dimensionsRef ?? []).map((d) => `@dim(${d})`)),
|
|
250
|
-
],
|
|
251
|
-
chartType: extractVisualizationChart(block),
|
|
252
|
-
metricRef: block.metricRef,
|
|
253
|
-
metricsRef: block.metricsRef,
|
|
254
|
-
tests,
|
|
255
|
-
tags,
|
|
256
|
-
description,
|
|
257
|
-
llmContext: agent.llmContext,
|
|
258
|
-
examples: agent.examples,
|
|
259
|
-
invariants: agent.invariants,
|
|
260
|
-
businessOutcome: agent.businessOutcome,
|
|
261
|
-
businessOwner: agent.businessOwner,
|
|
262
|
-
decisionUse: agent.decisionUse,
|
|
263
|
-
reviewCadence: agent.reviewCadence,
|
|
264
|
-
businessRules: agent.businessRules,
|
|
265
|
-
caveats: agent.caveats,
|
|
266
|
-
datalexContract: typeof block.datalexContract === 'string' ? block.datalexContract : undefined,
|
|
267
|
-
outputs: columnLineage?.parsed && columnLineage.columns.length > 0
|
|
268
|
-
? columnLineage.columns.map((c) => ({
|
|
269
|
-
name: c.name,
|
|
270
|
-
isAggregate: c.isAggregate,
|
|
271
|
-
aggregateFn: c.aggregateFn,
|
|
272
|
-
sources: c.sources,
|
|
273
|
-
unresolved: c.unresolved,
|
|
274
|
-
}))
|
|
275
|
-
: undefined,
|
|
276
|
-
};
|
|
272
|
+
blocks[block.name] = blockDeclToManifestBlock(block, relPath);
|
|
277
273
|
}
|
|
278
274
|
}
|
|
279
275
|
catch (err) {
|
|
@@ -291,8 +287,216 @@ function scanBlocks(projectRoot, dirs, diagnostics) {
|
|
|
291
287
|
}
|
|
292
288
|
return blocks;
|
|
293
289
|
}
|
|
290
|
+
// ---- Business View Scanning ----
|
|
291
|
+
function scanBusinessViews(projectRoot, dirs, diagnostics) {
|
|
292
|
+
const views = {};
|
|
293
|
+
const seenFiles = new Set();
|
|
294
|
+
for (const dir of dirs) {
|
|
295
|
+
const dirPath = join(projectRoot, dir);
|
|
296
|
+
const files = scanFilesRecursive(dirPath, ['.dql']);
|
|
297
|
+
for (const filePath of files) {
|
|
298
|
+
if (seenFiles.has(filePath))
|
|
299
|
+
continue;
|
|
300
|
+
seenFiles.add(filePath);
|
|
301
|
+
const relPath = relative(projectRoot, filePath);
|
|
302
|
+
try {
|
|
303
|
+
const source = readFileSync(filePath, 'utf-8');
|
|
304
|
+
if (!source.includes('business_view'))
|
|
305
|
+
continue;
|
|
306
|
+
const parser = new Parser(source, relPath);
|
|
307
|
+
const ast = parser.parse();
|
|
308
|
+
for (const stmt of ast.statements) {
|
|
309
|
+
const view = stmt;
|
|
310
|
+
if (view.kind !== 'BusinessViewDecl')
|
|
311
|
+
continue;
|
|
312
|
+
if (views[view.name]) {
|
|
313
|
+
diagnostics?.push({
|
|
314
|
+
kind: 'resolve',
|
|
315
|
+
filePath: relPath,
|
|
316
|
+
severity: 'error',
|
|
317
|
+
message: `duplicate business_view "${view.name}" also declared in ${views[view.name].filePath}`,
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
views[view.name] = businessViewDeclToManifestBusinessView(view, relPath);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
326
|
+
diagnostics?.push({
|
|
327
|
+
kind: 'parse',
|
|
328
|
+
filePath: relPath,
|
|
329
|
+
severity: 'error',
|
|
330
|
+
message: `Failed to parse business view file: ${msg}`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return views;
|
|
336
|
+
}
|
|
337
|
+
// ---- Business Term Scanning ----
|
|
338
|
+
function scanTerms(projectRoot, dirs, diagnostics) {
|
|
339
|
+
const terms = {};
|
|
340
|
+
const seenFiles = new Set();
|
|
341
|
+
for (const dir of dirs) {
|
|
342
|
+
const dirPath = join(projectRoot, dir);
|
|
343
|
+
const files = scanFilesRecursive(dirPath, ['.dql']);
|
|
344
|
+
for (const filePath of files) {
|
|
345
|
+
if (seenFiles.has(filePath))
|
|
346
|
+
continue;
|
|
347
|
+
seenFiles.add(filePath);
|
|
348
|
+
const relPath = relative(projectRoot, filePath);
|
|
349
|
+
try {
|
|
350
|
+
const source = readFileSync(filePath, 'utf-8');
|
|
351
|
+
if (!source.includes('term'))
|
|
352
|
+
continue;
|
|
353
|
+
const parser = new Parser(source, relPath);
|
|
354
|
+
const ast = parser.parse();
|
|
355
|
+
for (const stmt of ast.statements) {
|
|
356
|
+
const term = stmt;
|
|
357
|
+
if (term.kind !== 'TermDecl')
|
|
358
|
+
continue;
|
|
359
|
+
if (terms[term.name]) {
|
|
360
|
+
diagnostics?.push({
|
|
361
|
+
kind: 'resolve',
|
|
362
|
+
filePath: relPath,
|
|
363
|
+
severity: 'error',
|
|
364
|
+
message: `duplicate term "${term.name}" also declared in ${terms[term.name].filePath}`,
|
|
365
|
+
});
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
terms[term.name] = termDeclToManifestTerm(term, relPath);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
373
|
+
diagnostics?.push({
|
|
374
|
+
kind: 'parse',
|
|
375
|
+
filePath: relPath,
|
|
376
|
+
severity: 'error',
|
|
377
|
+
message: `Failed to parse term file: ${msg}`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return terms;
|
|
383
|
+
}
|
|
384
|
+
function validateBusinessViews(views, blocks, diagnostics) {
|
|
385
|
+
const blockNames = new Set(Object.keys(blocks));
|
|
386
|
+
const viewNames = new Set(Object.keys(views));
|
|
387
|
+
for (const view of Object.values(views)) {
|
|
388
|
+
view.unresolvedBlockRefs = view.blockRefs.filter((ref) => !blockNames.has(ref));
|
|
389
|
+
view.blockRefs = view.blockRefs.filter((ref) => blockNames.has(ref));
|
|
390
|
+
view.unresolvedBusinessViewRefs = view.businessViewRefs.filter((ref) => !viewNames.has(ref));
|
|
391
|
+
view.businessViewRefs = view.businessViewRefs.filter((ref) => viewNames.has(ref));
|
|
392
|
+
if (view.unresolvedBlockRefs.length > 0) {
|
|
393
|
+
diagnostics.push({
|
|
394
|
+
kind: 'resolve',
|
|
395
|
+
filePath: view.filePath,
|
|
396
|
+
severity: 'error',
|
|
397
|
+
message: `business_view "${view.name}" has unresolved block refs: ${view.unresolvedBlockRefs.join(', ')}`,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (view.unresolvedBusinessViewRefs.length > 0) {
|
|
401
|
+
diagnostics.push({
|
|
402
|
+
kind: 'resolve',
|
|
403
|
+
filePath: view.filePath,
|
|
404
|
+
severity: 'error',
|
|
405
|
+
message: `business_view "${view.name}" has unresolved business_view refs: ${view.unresolvedBusinessViewRefs.join(', ')}`,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
detectBusinessViewCycles(views, diagnostics);
|
|
410
|
+
}
|
|
411
|
+
function validateTermRefs(terms, blocks, views, diagnostics) {
|
|
412
|
+
const termNames = new Set(Object.keys(terms));
|
|
413
|
+
for (const block of Object.values(blocks)) {
|
|
414
|
+
const declared = block.termRefs ?? [];
|
|
415
|
+
block.unresolvedTermRefs = declared.filter((ref) => !termNames.has(ref));
|
|
416
|
+
block.termRefs = declared.filter((ref) => termNames.has(ref));
|
|
417
|
+
if (block.unresolvedTermRefs.length > 0) {
|
|
418
|
+
diagnostics.push({
|
|
419
|
+
kind: 'resolve',
|
|
420
|
+
filePath: block.filePath,
|
|
421
|
+
severity: 'error',
|
|
422
|
+
message: `block "${block.name}" has unresolved term refs: ${block.unresolvedTermRefs.join(', ')}`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
for (const view of Object.values(views)) {
|
|
427
|
+
const declared = view.declaredTermRefs ?? [];
|
|
428
|
+
view.unresolvedTermRefs = declared.filter((ref) => !termNames.has(ref));
|
|
429
|
+
view.declaredTermRefs = declared.filter((ref) => termNames.has(ref));
|
|
430
|
+
if (view.unresolvedTermRefs.length > 0) {
|
|
431
|
+
diagnostics.push({
|
|
432
|
+
kind: 'resolve',
|
|
433
|
+
filePath: view.filePath,
|
|
434
|
+
severity: 'error',
|
|
435
|
+
message: `business_view "${view.name}" has unresolved term refs: ${view.unresolvedTermRefs.join(', ')}`,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const collectTermsForView = (viewName, seen = new Set()) => {
|
|
440
|
+
const view = views[viewName];
|
|
441
|
+
if (!view || seen.has(viewName))
|
|
442
|
+
return [];
|
|
443
|
+
seen.add(viewName);
|
|
444
|
+
const refs = new Set(view.declaredTermRefs ?? []);
|
|
445
|
+
for (const blockRef of view.blockRefs) {
|
|
446
|
+
for (const termRef of blocks[blockRef]?.termRefs ?? [])
|
|
447
|
+
refs.add(termRef);
|
|
448
|
+
}
|
|
449
|
+
for (const viewRef of view.businessViewRefs) {
|
|
450
|
+
for (const termRef of collectTermsForView(viewRef, seen))
|
|
451
|
+
refs.add(termRef);
|
|
452
|
+
}
|
|
453
|
+
return [...refs];
|
|
454
|
+
};
|
|
455
|
+
for (const view of Object.values(views)) {
|
|
456
|
+
const declaredSet = new Set(view.declaredTermRefs ?? []);
|
|
457
|
+
const allRefs = collectTermsForView(view.name);
|
|
458
|
+
view.termRefs = allRefs;
|
|
459
|
+
view.inheritedTermRefs = allRefs.filter((ref) => !declaredSet.has(ref));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function detectBusinessViewCycles(views, diagnostics) {
|
|
463
|
+
const visiting = new Set();
|
|
464
|
+
const visited = new Set();
|
|
465
|
+
const stack = [];
|
|
466
|
+
const reported = new Set();
|
|
467
|
+
const visit = (name) => {
|
|
468
|
+
if (visited.has(name))
|
|
469
|
+
return;
|
|
470
|
+
if (visiting.has(name)) {
|
|
471
|
+
const cycleStart = stack.indexOf(name);
|
|
472
|
+
const cycle = [...stack.slice(cycleStart), name];
|
|
473
|
+
const key = cycle.join(' -> ');
|
|
474
|
+
if (!reported.has(key)) {
|
|
475
|
+
reported.add(key);
|
|
476
|
+
diagnostics.push({
|
|
477
|
+
kind: 'resolve',
|
|
478
|
+
filePath: views[name]?.filePath,
|
|
479
|
+
severity: 'error',
|
|
480
|
+
message: `business_view cycle detected: ${cycle.join(' -> ')}`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
visiting.add(name);
|
|
486
|
+
stack.push(name);
|
|
487
|
+
for (const ref of views[name]?.businessViewRefs ?? []) {
|
|
488
|
+
visit(ref);
|
|
489
|
+
}
|
|
490
|
+
stack.pop();
|
|
491
|
+
visiting.delete(name);
|
|
492
|
+
visited.add(name);
|
|
493
|
+
};
|
|
494
|
+
for (const name of Object.keys(views)) {
|
|
495
|
+
visit(name);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
294
498
|
// ---- Notebook Scanning ----
|
|
295
|
-
function scanNotebooks(projectRoot, dirs, diagnostics) {
|
|
499
|
+
function scanNotebooks(projectRoot, dirs, diagnostics, datalexRegistry) {
|
|
296
500
|
const notebooks = {};
|
|
297
501
|
const seen = new Set();
|
|
298
502
|
for (const dir of dirs) {
|
|
@@ -323,6 +527,7 @@ function scanNotebooks(projectRoot, dirs, diagnostics) {
|
|
|
323
527
|
try {
|
|
324
528
|
const parser = new Parser(source, `${relPath}:${cell.id}`);
|
|
325
529
|
const ast = parser.parse();
|
|
530
|
+
collectContractDiagnostics(ast, relPath, diagnostics, datalexRegistry);
|
|
326
531
|
for (const stmt of ast.statements) {
|
|
327
532
|
if (stmt.kind === 'BlockDecl') {
|
|
328
533
|
blockName = stmt.name;
|
|
@@ -372,7 +577,7 @@ function scanNotebooks(projectRoot, dirs, diagnostics) {
|
|
|
372
577
|
return notebooks;
|
|
373
578
|
}
|
|
374
579
|
/** Extract blocks declared inside notebook DQL cells into the blocks map. */
|
|
375
|
-
function extractNotebookBlocks(notebooks
|
|
580
|
+
function extractNotebookBlocks(notebooks) {
|
|
376
581
|
const blocks = {};
|
|
377
582
|
for (const [nbPath, nb] of Object.entries(notebooks)) {
|
|
378
583
|
for (const cell of nb.cells) {
|
|
@@ -380,17 +585,26 @@ function extractNotebookBlocks(notebooks, projectRoot) {
|
|
|
380
585
|
continue;
|
|
381
586
|
// Only add if not already found as a standalone block file
|
|
382
587
|
if (!blocks[cell.blockName]) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
name
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
588
|
+
try {
|
|
589
|
+
const ast = new Parser(cell.source, `${nbPath}:${cell.id}`).parse();
|
|
590
|
+
const block = ast.statements.find((stmt) => stmt.kind === 'BlockDecl' && stmt.name === cell.blockName);
|
|
591
|
+
if (block) {
|
|
592
|
+
blocks[cell.blockName] = blockDeclToManifestBlock(block, `${nbPath}#${cell.id}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
const parseResult = extractTablesFromSql(cell.source);
|
|
597
|
+
blocks[cell.blockName] = {
|
|
598
|
+
name: cell.blockName,
|
|
599
|
+
filePath: `${nbPath}#${cell.id}`,
|
|
600
|
+
sql: '',
|
|
601
|
+
rawTableRefs: parseResult.tables,
|
|
602
|
+
tableDependencies: parseResult.tables.map(normalizeTableName),
|
|
603
|
+
refDependencies: parseResult.refs,
|
|
604
|
+
allDependencies: [...parseResult.refs, ...parseResult.tables.map(normalizeTableName)],
|
|
605
|
+
tests: [],
|
|
606
|
+
};
|
|
607
|
+
}
|
|
394
608
|
}
|
|
395
609
|
}
|
|
396
610
|
}
|
|
@@ -960,7 +1174,7 @@ function appDocumentToManifest(app, filePath, dashboardIds) {
|
|
|
960
1174
|
};
|
|
961
1175
|
}
|
|
962
1176
|
// ---- Lineage Builder ----
|
|
963
|
-
function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport, apps, appDashboards) {
|
|
1177
|
+
function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport, apps, appDashboards, businessViews, terms) {
|
|
964
1178
|
// Convert to lineage builder input format
|
|
965
1179
|
const lineageBlocks = Object.values(blocks).map((b) => ({
|
|
966
1180
|
name: b.name,
|
|
@@ -974,6 +1188,19 @@ function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport,
|
|
|
974
1188
|
dimensionsRef: b.dimensionsRef,
|
|
975
1189
|
chartType: b.chartType,
|
|
976
1190
|
filePath: b.filePath,
|
|
1191
|
+
termRefs: b.termRefs,
|
|
1192
|
+
}));
|
|
1193
|
+
const lineageTerms = Object.values(terms ?? {}).map((term) => ({
|
|
1194
|
+
name: term.name,
|
|
1195
|
+
domain: term.domain,
|
|
1196
|
+
owner: term.owner,
|
|
1197
|
+
status: term.status,
|
|
1198
|
+
termType: term.termType,
|
|
1199
|
+
filePath: term.filePath,
|
|
1200
|
+
description: term.description,
|
|
1201
|
+
identifiers: term.identifiers,
|
|
1202
|
+
synonyms: term.synonyms,
|
|
1203
|
+
businessOutcome: term.businessOutcome,
|
|
977
1204
|
}));
|
|
978
1205
|
const lineageMetrics = Object.values(metrics).map((m) => ({
|
|
979
1206
|
name: m.name,
|
|
@@ -985,6 +1212,19 @@ function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport,
|
|
|
985
1212
|
name: d.name,
|
|
986
1213
|
table: d.table,
|
|
987
1214
|
}));
|
|
1215
|
+
const lineageBusinessViews = Object.values(businessViews ?? {}).map((view) => ({
|
|
1216
|
+
name: view.name,
|
|
1217
|
+
domain: view.domain,
|
|
1218
|
+
owner: view.owner,
|
|
1219
|
+
status: view.status,
|
|
1220
|
+
filePath: view.filePath,
|
|
1221
|
+
description: view.description,
|
|
1222
|
+
businessOutcome: view.businessOutcome,
|
|
1223
|
+
blockRefs: view.blockRefs,
|
|
1224
|
+
businessViewRefs: view.businessViewRefs,
|
|
1225
|
+
termRefs: view.termRefs,
|
|
1226
|
+
declaredTermRefs: view.declaredTermRefs,
|
|
1227
|
+
}));
|
|
988
1228
|
// Pre-build a map of table name → block names that query that table.
|
|
989
1229
|
// Used to resolve "which blocks feed into a notebook" when a notebook
|
|
990
1230
|
// references a table directly rather than via ref().
|
|
@@ -1084,6 +1324,8 @@ function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport,
|
|
|
1084
1324
|
dbtModels,
|
|
1085
1325
|
dashboards: [...dashboards, ...appDashboardInputs],
|
|
1086
1326
|
apps: lineageApps,
|
|
1327
|
+
businessViews: lineageBusinessViews,
|
|
1328
|
+
terms: lineageTerms,
|
|
1087
1329
|
});
|
|
1088
1330
|
// Serialize to manifest format
|
|
1089
1331
|
const graphJson = graph.toJSON();
|
|
@@ -1107,7 +1349,7 @@ function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport,
|
|
|
1107
1349
|
domain: n.domain,
|
|
1108
1350
|
owner: n.owner,
|
|
1109
1351
|
status: n.status,
|
|
1110
|
-
filePath: blocks[n.name]?.filePath ?? n.metadata?.filePath,
|
|
1352
|
+
filePath: blocks[n.name]?.filePath ?? businessViews?.[n.name]?.filePath ?? terms?.[n.name]?.filePath ?? n.metadata?.filePath,
|
|
1111
1353
|
columns: n.columns,
|
|
1112
1354
|
metadata: n.metadata,
|
|
1113
1355
|
})),
|
|
@@ -1128,6 +1370,138 @@ function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport,
|
|
|
1128
1370
|
};
|
|
1129
1371
|
}
|
|
1130
1372
|
// ---- AST Helpers ----
|
|
1373
|
+
function blockDeclToManifestBlock(block, filePath) {
|
|
1374
|
+
const sql = block.query?.rawSQL ?? '';
|
|
1375
|
+
const parseResult = extractTablesFromSql(sql);
|
|
1376
|
+
const columnLineage = sql ? extractColumnLineage(sql) : null;
|
|
1377
|
+
const domain = extractProp(block, 'domain');
|
|
1378
|
+
const owner = extractProp(block, 'owner');
|
|
1379
|
+
const description = extractProp(block, 'description');
|
|
1380
|
+
const tags = extractTags(block);
|
|
1381
|
+
const tests = extractTests(block);
|
|
1382
|
+
const agent = extractAgentMetadata(block);
|
|
1383
|
+
return {
|
|
1384
|
+
name: block.name,
|
|
1385
|
+
filePath,
|
|
1386
|
+
domain,
|
|
1387
|
+
owner,
|
|
1388
|
+
status: extractProp(block, 'status'),
|
|
1389
|
+
blockType: block.blockType,
|
|
1390
|
+
sql,
|
|
1391
|
+
rawTableRefs: parseResult.tables,
|
|
1392
|
+
tableDependencies: parseResult.tables.map(normalizeTableName),
|
|
1393
|
+
refDependencies: parseResult.refs,
|
|
1394
|
+
metricRefs: parseResult.metricRefs,
|
|
1395
|
+
dimensionRefs: parseResult.dimensionRefs,
|
|
1396
|
+
dimensionsRef: block.dimensionsRef,
|
|
1397
|
+
allDependencies: [
|
|
1398
|
+
...parseResult.refs,
|
|
1399
|
+
...parseResult.tables.map(normalizeTableName),
|
|
1400
|
+
...parseResult.metricRefs.map((m) => `@metric(${m})`),
|
|
1401
|
+
...parseResult.dimensionRefs.map((d) => `@dim(${d})`),
|
|
1402
|
+
...((block.metricsRef ?? (block.metricRef ? [block.metricRef] : [])).map((m) => `@metric(${m})`)),
|
|
1403
|
+
...((block.dimensionsRef ?? []).map((d) => `@dim(${d})`)),
|
|
1404
|
+
],
|
|
1405
|
+
chartType: extractVisualizationChart(block),
|
|
1406
|
+
metricRef: block.metricRef,
|
|
1407
|
+
metricsRef: block.metricsRef,
|
|
1408
|
+
tests,
|
|
1409
|
+
tags,
|
|
1410
|
+
description,
|
|
1411
|
+
termRefs: Array.isArray(block.termRefs) ? block.termRefs : undefined,
|
|
1412
|
+
unresolvedTermRefs: [],
|
|
1413
|
+
llmContext: agent.llmContext,
|
|
1414
|
+
examples: agent.examples,
|
|
1415
|
+
invariants: agent.invariants,
|
|
1416
|
+
businessOutcome: agent.businessOutcome,
|
|
1417
|
+
businessOwner: agent.businessOwner,
|
|
1418
|
+
decisionUse: agent.decisionUse,
|
|
1419
|
+
reviewCadence: agent.reviewCadence,
|
|
1420
|
+
businessRules: agent.businessRules,
|
|
1421
|
+
caveats: agent.caveats,
|
|
1422
|
+
datalexContract: typeof block.datalexContract === 'string' ? block.datalexContract : undefined,
|
|
1423
|
+
outputs: columnLineage?.parsed && columnLineage.columns.length > 0
|
|
1424
|
+
? columnLineage.columns.map((c) => ({
|
|
1425
|
+
name: c.name,
|
|
1426
|
+
isAggregate: c.isAggregate,
|
|
1427
|
+
aggregateFn: c.aggregateFn,
|
|
1428
|
+
sources: c.sources,
|
|
1429
|
+
unresolved: c.unresolved,
|
|
1430
|
+
}))
|
|
1431
|
+
: undefined,
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
function termDeclToManifestTerm(term, filePath) {
|
|
1435
|
+
return {
|
|
1436
|
+
name: term.name,
|
|
1437
|
+
filePath,
|
|
1438
|
+
domain: extractProp(term, 'domain'),
|
|
1439
|
+
owner: extractProp(term, 'owner'),
|
|
1440
|
+
status: extractProp(term, 'status'),
|
|
1441
|
+
termType: typeof term.termType === 'string' ? term.termType : undefined,
|
|
1442
|
+
tags: extractTags(term),
|
|
1443
|
+
description: extractProp(term, 'description'),
|
|
1444
|
+
identifiers: Array.isArray(term.identifiers) ? term.identifiers : undefined,
|
|
1445
|
+
synonyms: Array.isArray(term.synonyms) ? term.synonyms : undefined,
|
|
1446
|
+
businessOutcome: typeof term.businessOutcome === 'string' ? term.businessOutcome : undefined,
|
|
1447
|
+
businessOwner: typeof term.businessOwner === 'string' ? term.businessOwner : undefined,
|
|
1448
|
+
decisionUse: typeof term.decisionUse === 'string' ? term.decisionUse : undefined,
|
|
1449
|
+
reviewCadence: typeof term.reviewCadence === 'string' ? term.reviewCadence : undefined,
|
|
1450
|
+
businessRules: Array.isArray(term.businessRules) ? term.businessRules : undefined,
|
|
1451
|
+
caveats: Array.isArray(term.caveats) ? term.caveats : undefined,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
function businessViewDeclToManifestBusinessView(view, filePath) {
|
|
1455
|
+
const blockRefs = [];
|
|
1456
|
+
const businessViewRefs = [];
|
|
1457
|
+
for (const ref of view.includes ?? []) {
|
|
1458
|
+
if (ref.refType === 'block')
|
|
1459
|
+
blockRefs.push(ref.name);
|
|
1460
|
+
if (ref.refType === 'business_view')
|
|
1461
|
+
businessViewRefs.push(ref.name);
|
|
1462
|
+
}
|
|
1463
|
+
return {
|
|
1464
|
+
name: view.name,
|
|
1465
|
+
filePath,
|
|
1466
|
+
domain: extractProp(view, 'domain'),
|
|
1467
|
+
owner: extractProp(view, 'owner'),
|
|
1468
|
+
status: extractProp(view, 'status'),
|
|
1469
|
+
tags: extractTags(view),
|
|
1470
|
+
description: extractProp(view, 'description'),
|
|
1471
|
+
businessOutcome: typeof view.businessOutcome === 'string' ? view.businessOutcome : undefined,
|
|
1472
|
+
businessOwner: typeof view.businessOwner === 'string' ? view.businessOwner : undefined,
|
|
1473
|
+
decisionUse: typeof view.decisionUse === 'string' ? view.decisionUse : undefined,
|
|
1474
|
+
reviewCadence: typeof view.reviewCadence === 'string' ? view.reviewCadence : undefined,
|
|
1475
|
+
businessRules: Array.isArray(view.businessRules) ? view.businessRules : undefined,
|
|
1476
|
+
caveats: Array.isArray(view.caveats) ? view.caveats : undefined,
|
|
1477
|
+
blockRefs,
|
|
1478
|
+
businessViewRefs,
|
|
1479
|
+
termRefs: Array.isArray(view.termRefs) ? view.termRefs : [],
|
|
1480
|
+
declaredTermRefs: Array.isArray(view.termRefs) ? view.termRefs : [],
|
|
1481
|
+
inheritedTermRefs: [],
|
|
1482
|
+
unresolvedTermRefs: [],
|
|
1483
|
+
unresolvedBlockRefs: [],
|
|
1484
|
+
unresolvedBusinessViewRefs: [],
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
function collectContractDiagnostics(ast, relPath, diagnostics, datalexRegistry) {
|
|
1488
|
+
if (!diagnostics)
|
|
1489
|
+
return;
|
|
1490
|
+
const hasContractRef = ast.statements.some((stmt) => stmt.kind === 'BlockDecl' && typeof stmt.datalexContract === 'string' && stmt.datalexContract.trim() !== '');
|
|
1491
|
+
if (!hasContractRef)
|
|
1492
|
+
return;
|
|
1493
|
+
const semanticDiagnostics = analyze(ast, { datalexRegistry });
|
|
1494
|
+
for (const diag of semanticDiagnostics) {
|
|
1495
|
+
if (!diag.message.includes('datalex_contract') && !diag.message.includes('DataLex manifest'))
|
|
1496
|
+
continue;
|
|
1497
|
+
diagnostics.push({
|
|
1498
|
+
kind: 'semantic',
|
|
1499
|
+
filePath: relPath,
|
|
1500
|
+
severity: diag.severity === 'error' ? 'error' : 'warning',
|
|
1501
|
+
message: diag.message,
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1131
1505
|
function extractProp(block, propName) {
|
|
1132
1506
|
if (block[propName] !== undefined && block[propName] !== null) {
|
|
1133
1507
|
return String(block[propName]);
|
|
@@ -1145,6 +1519,9 @@ function extractVisualizationChart(block) {
|
|
|
1145
1519
|
if (!block.visualization)
|
|
1146
1520
|
return undefined;
|
|
1147
1521
|
for (const prop of block.visualization.properties ?? []) {
|
|
1522
|
+
if (prop.name === 'chart' && prop.value?.kind === 'StringLiteral') {
|
|
1523
|
+
return String(prop.value.value);
|
|
1524
|
+
}
|
|
1148
1525
|
if (prop.key === 'chart' && prop.value?.kind === 'Literal') {
|
|
1149
1526
|
return String(prop.value.value);
|
|
1150
1527
|
}
|
|
@@ -1185,6 +1562,14 @@ function extractAgentMetadata(block) {
|
|
|
1185
1562
|
};
|
|
1186
1563
|
}
|
|
1187
1564
|
function extractTests(block) {
|
|
1565
|
+
if (Array.isArray(block.tests)) {
|
|
1566
|
+
return block.tests.map((a) => {
|
|
1567
|
+
if (typeof a === 'string')
|
|
1568
|
+
return a;
|
|
1569
|
+
const expected = formatManifestTestExpected(a.expected);
|
|
1570
|
+
return `${a.field ?? ''} ${a.operator ?? ''} ${expected}`.trim();
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1188
1573
|
if (!block.tests?.assertions)
|
|
1189
1574
|
return [];
|
|
1190
1575
|
return block.tests.assertions.map((a) => {
|
|
@@ -1193,6 +1578,23 @@ function extractTests(block) {
|
|
|
1193
1578
|
return `${a.field ?? ''} ${a.operator ?? ''} ${a.expected ?? ''}`.trim();
|
|
1194
1579
|
});
|
|
1195
1580
|
}
|
|
1581
|
+
function formatManifestTestExpected(node) {
|
|
1582
|
+
if (!node || typeof node !== 'object')
|
|
1583
|
+
return String(node ?? '');
|
|
1584
|
+
switch (node.kind) {
|
|
1585
|
+
case 'StringLiteral':
|
|
1586
|
+
return JSON.stringify(node.value);
|
|
1587
|
+
case 'NumberLiteral':
|
|
1588
|
+
case 'BooleanLiteral':
|
|
1589
|
+
return String(node.value);
|
|
1590
|
+
case 'Identifier':
|
|
1591
|
+
return node.name;
|
|
1592
|
+
case 'ArrayLiteral':
|
|
1593
|
+
return `[${(node.elements ?? []).map(formatManifestTestExpected).join(', ')}]`;
|
|
1594
|
+
default:
|
|
1595
|
+
return String(node.value ?? '');
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1196
1598
|
/**
|
|
1197
1599
|
* Normalize DuckDB reader function calls to plain table names.
|
|
1198
1600
|
* e.g., "read_csv_auto('./data/revenue.csv')" → "revenue"
|