@duckcodeailabs/dql-core 1.6.0 → 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/README.md +7 -5
- 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 +51 -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 +62 -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 +122 -0
- package/dist/formatter/formatter.js.map +1 -1
- package/dist/lexer/token.d.ts +5 -0
- package/dist/lexer/token.d.ts.map +1 -1
- package/dist/lexer/token.js +10 -0
- package/dist/lexer/token.js.map +1 -1
- package/dist/lineage/builder.d.ts +33 -0
- package/dist/lineage/builder.d.ts.map +1 -1
- package/dist/lineage/builder.js +108 -11
- 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 +481 -73
- 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 +63 -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 +320 -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/dist/semantic/semantic-layer.d.ts.map +1 -1
- package/dist/semantic/semantic-layer.js +24 -2
- package/dist/semantic/semantic-layer.js.map +1 -1
- package/package.json +2 -2
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,63 +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
|
-
allDependencies: [
|
|
243
|
-
...parseResult.refs,
|
|
244
|
-
...parseResult.tables.map(normalizeTableName),
|
|
245
|
-
...parseResult.metricRefs.map((m) => `@metric(${m})`),
|
|
246
|
-
...parseResult.dimensionRefs.map((d) => `@dim(${d})`),
|
|
247
|
-
],
|
|
248
|
-
chartType: extractVisualizationChart(block),
|
|
249
|
-
metricRef: block.metricRef,
|
|
250
|
-
tests,
|
|
251
|
-
tags,
|
|
252
|
-
description,
|
|
253
|
-
llmContext: agent.llmContext,
|
|
254
|
-
examples: agent.examples,
|
|
255
|
-
invariants: agent.invariants,
|
|
256
|
-
businessOutcome: agent.businessOutcome,
|
|
257
|
-
businessOwner: agent.businessOwner,
|
|
258
|
-
decisionUse: agent.decisionUse,
|
|
259
|
-
reviewCadence: agent.reviewCadence,
|
|
260
|
-
businessRules: agent.businessRules,
|
|
261
|
-
caveats: agent.caveats,
|
|
262
|
-
datalexContract: typeof block.datalexContract === 'string' ? block.datalexContract : undefined,
|
|
263
|
-
outputs: columnLineage?.parsed && columnLineage.columns.length > 0
|
|
264
|
-
? columnLineage.columns.map((c) => ({
|
|
265
|
-
name: c.name,
|
|
266
|
-
isAggregate: c.isAggregate,
|
|
267
|
-
aggregateFn: c.aggregateFn,
|
|
268
|
-
sources: c.sources,
|
|
269
|
-
unresolved: c.unresolved,
|
|
270
|
-
}))
|
|
271
|
-
: undefined,
|
|
272
|
-
};
|
|
272
|
+
blocks[block.name] = blockDeclToManifestBlock(block, relPath);
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
catch (err) {
|
|
@@ -287,8 +287,216 @@ function scanBlocks(projectRoot, dirs, diagnostics) {
|
|
|
287
287
|
}
|
|
288
288
|
return blocks;
|
|
289
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
|
+
}
|
|
290
498
|
// ---- Notebook Scanning ----
|
|
291
|
-
function scanNotebooks(projectRoot, dirs, diagnostics) {
|
|
499
|
+
function scanNotebooks(projectRoot, dirs, diagnostics, datalexRegistry) {
|
|
292
500
|
const notebooks = {};
|
|
293
501
|
const seen = new Set();
|
|
294
502
|
for (const dir of dirs) {
|
|
@@ -319,6 +527,7 @@ function scanNotebooks(projectRoot, dirs, diagnostics) {
|
|
|
319
527
|
try {
|
|
320
528
|
const parser = new Parser(source, `${relPath}:${cell.id}`);
|
|
321
529
|
const ast = parser.parse();
|
|
530
|
+
collectContractDiagnostics(ast, relPath, diagnostics, datalexRegistry);
|
|
322
531
|
for (const stmt of ast.statements) {
|
|
323
532
|
if (stmt.kind === 'BlockDecl') {
|
|
324
533
|
blockName = stmt.name;
|
|
@@ -368,7 +577,7 @@ function scanNotebooks(projectRoot, dirs, diagnostics) {
|
|
|
368
577
|
return notebooks;
|
|
369
578
|
}
|
|
370
579
|
/** Extract blocks declared inside notebook DQL cells into the blocks map. */
|
|
371
|
-
function extractNotebookBlocks(notebooks
|
|
580
|
+
function extractNotebookBlocks(notebooks) {
|
|
372
581
|
const blocks = {};
|
|
373
582
|
for (const [nbPath, nb] of Object.entries(notebooks)) {
|
|
374
583
|
for (const cell of nb.cells) {
|
|
@@ -376,17 +585,26 @@ function extractNotebookBlocks(notebooks, projectRoot) {
|
|
|
376
585
|
continue;
|
|
377
586
|
// Only add if not already found as a standalone block file
|
|
378
587
|
if (!blocks[cell.blockName]) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
name
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
}
|
|
390
608
|
}
|
|
391
609
|
}
|
|
392
610
|
}
|
|
@@ -956,7 +1174,7 @@ function appDocumentToManifest(app, filePath, dashboardIds) {
|
|
|
956
1174
|
};
|
|
957
1175
|
}
|
|
958
1176
|
// ---- Lineage Builder ----
|
|
959
|
-
function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport, apps, appDashboards) {
|
|
1177
|
+
function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport, apps, appDashboards, businessViews, terms) {
|
|
960
1178
|
// Convert to lineage builder input format
|
|
961
1179
|
const lineageBlocks = Object.values(blocks).map((b) => ({
|
|
962
1180
|
name: b.name,
|
|
@@ -966,8 +1184,23 @@ function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport,
|
|
|
966
1184
|
status: b.status,
|
|
967
1185
|
blockType: b.blockType,
|
|
968
1186
|
metricRef: b.metricRef,
|
|
1187
|
+
metricsRef: b.metricsRef,
|
|
1188
|
+
dimensionsRef: b.dimensionsRef,
|
|
969
1189
|
chartType: b.chartType,
|
|
970
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,
|
|
971
1204
|
}));
|
|
972
1205
|
const lineageMetrics = Object.values(metrics).map((m) => ({
|
|
973
1206
|
name: m.name,
|
|
@@ -979,6 +1212,19 @@ function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport,
|
|
|
979
1212
|
name: d.name,
|
|
980
1213
|
table: d.table,
|
|
981
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
|
+
}));
|
|
982
1228
|
// Pre-build a map of table name → block names that query that table.
|
|
983
1229
|
// Used to resolve "which blocks feed into a notebook" when a notebook
|
|
984
1230
|
// references a table directly rather than via ref().
|
|
@@ -1078,6 +1324,8 @@ function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport,
|
|
|
1078
1324
|
dbtModels,
|
|
1079
1325
|
dashboards: [...dashboards, ...appDashboardInputs],
|
|
1080
1326
|
apps: lineageApps,
|
|
1327
|
+
businessViews: lineageBusinessViews,
|
|
1328
|
+
terms: lineageTerms,
|
|
1081
1329
|
});
|
|
1082
1330
|
// Serialize to manifest format
|
|
1083
1331
|
const graphJson = graph.toJSON();
|
|
@@ -1101,7 +1349,7 @@ function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport,
|
|
|
1101
1349
|
domain: n.domain,
|
|
1102
1350
|
owner: n.owner,
|
|
1103
1351
|
status: n.status,
|
|
1104
|
-
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,
|
|
1105
1353
|
columns: n.columns,
|
|
1106
1354
|
metadata: n.metadata,
|
|
1107
1355
|
})),
|
|
@@ -1122,6 +1370,138 @@ function buildManifestLineage(blocks, metrics, dimensions, notebooks, dbtImport,
|
|
|
1122
1370
|
};
|
|
1123
1371
|
}
|
|
1124
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
|
+
}
|
|
1125
1505
|
function extractProp(block, propName) {
|
|
1126
1506
|
if (block[propName] !== undefined && block[propName] !== null) {
|
|
1127
1507
|
return String(block[propName]);
|
|
@@ -1139,6 +1519,9 @@ function extractVisualizationChart(block) {
|
|
|
1139
1519
|
if (!block.visualization)
|
|
1140
1520
|
return undefined;
|
|
1141
1521
|
for (const prop of block.visualization.properties ?? []) {
|
|
1522
|
+
if (prop.name === 'chart' && prop.value?.kind === 'StringLiteral') {
|
|
1523
|
+
return String(prop.value.value);
|
|
1524
|
+
}
|
|
1142
1525
|
if (prop.key === 'chart' && prop.value?.kind === 'Literal') {
|
|
1143
1526
|
return String(prop.value.value);
|
|
1144
1527
|
}
|
|
@@ -1179,6 +1562,14 @@ function extractAgentMetadata(block) {
|
|
|
1179
1562
|
};
|
|
1180
1563
|
}
|
|
1181
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
|
+
}
|
|
1182
1573
|
if (!block.tests?.assertions)
|
|
1183
1574
|
return [];
|
|
1184
1575
|
return block.tests.assertions.map((a) => {
|
|
@@ -1187,6 +1578,23 @@ function extractTests(block) {
|
|
|
1187
1578
|
return `${a.field ?? ''} ${a.operator ?? ''} ${a.expected ?? ''}`.trim();
|
|
1188
1579
|
});
|
|
1189
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
|
+
}
|
|
1190
1598
|
/**
|
|
1191
1599
|
* Normalize DuckDB reader function calls to plain table names.
|
|
1192
1600
|
* e.g., "read_csv_auto('./data/revenue.csv')" → "revenue"
|