@duckcodeailabs/dql-cli 1.6.18 → 1.6.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/apps-api.d.ts +41 -1
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +297 -18
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/args.d.ts +4 -0
  6. package/dist/args.d.ts.map +1 -1
  7. package/dist/args.js +10 -1
  8. package/dist/args.js.map +1 -1
  9. package/dist/assets/dql-notebook/assets/index-CcpAv4lQ.js +3864 -0
  10. package/dist/assets/dql-notebook/assets/index-rJXLSheJ.js +3910 -0
  11. package/dist/assets/dql-notebook/index.html +1 -1
  12. package/dist/block-studio-import.d.ts +99 -3
  13. package/dist/block-studio-import.d.ts.map +1 -1
  14. package/dist/block-studio-import.js +772 -27
  15. package/dist/block-studio-import.js.map +1 -1
  16. package/dist/commands/agent.d.ts.map +1 -1
  17. package/dist/commands/agent.js +12 -1
  18. package/dist/commands/agent.js.map +1 -1
  19. package/dist/commands/app.d.ts +2 -1
  20. package/dist/commands/app.d.ts.map +1 -1
  21. package/dist/commands/app.js +22 -4
  22. package/dist/commands/app.js.map +1 -1
  23. package/dist/commands/certify.d.ts.map +1 -1
  24. package/dist/commands/certify.js +104 -10
  25. package/dist/commands/certify.js.map +1 -1
  26. package/dist/commands/compile.d.ts +1 -0
  27. package/dist/commands/compile.d.ts.map +1 -1
  28. package/dist/commands/compile.js +26 -4
  29. package/dist/commands/compile.js.map +1 -1
  30. package/dist/commands/doctor.d.ts.map +1 -1
  31. package/dist/commands/doctor.js +345 -6
  32. package/dist/commands/doctor.js.map +1 -1
  33. package/dist/commands/import.d.ts.map +1 -1
  34. package/dist/commands/import.js +45 -70
  35. package/dist/commands/import.js.map +1 -1
  36. package/dist/commands/lineage.d.ts +2 -0
  37. package/dist/commands/lineage.d.ts.map +1 -1
  38. package/dist/commands/lineage.js +111 -0
  39. package/dist/commands/lineage.js.map +1 -1
  40. package/dist/commands/migrate.d.ts +1 -0
  41. package/dist/commands/migrate.d.ts.map +1 -1
  42. package/dist/commands/migrate.js +106 -2
  43. package/dist/commands/migrate.js.map +1 -1
  44. package/dist/commands/new.d.ts.map +1 -1
  45. package/dist/commands/new.js +246 -9
  46. package/dist/commands/new.js.map +1 -1
  47. package/dist/commands/promote.d.ts +11 -0
  48. package/dist/commands/promote.d.ts.map +1 -0
  49. package/dist/commands/promote.js +236 -0
  50. package/dist/commands/promote.js.map +1 -0
  51. package/dist/commands/semantic.js +2 -2
  52. package/dist/commands/semantic.js.map +1 -1
  53. package/dist/index.js +48 -8
  54. package/dist/index.js.map +1 -1
  55. package/dist/llm/providers/claude-agent-sdk.js +1 -1
  56. package/dist/llm/providers/claude-agent-sdk.js.map +1 -1
  57. package/dist/llm/tools.js +1 -1
  58. package/dist/llm/tools.js.map +1 -1
  59. package/dist/local-runtime.d.ts +176 -5
  60. package/dist/local-runtime.d.ts.map +1 -1
  61. package/dist/local-runtime.js +3681 -146
  62. package/dist/local-runtime.js.map +1 -1
  63. package/dist/package.json +10 -10
  64. package/dist/promote-from-draft.d.ts +11 -10
  65. package/dist/promote-from-draft.d.ts.map +1 -1
  66. package/dist/promote-from-draft.js +53 -30
  67. package/dist/promote-from-draft.js.map +1 -1
  68. package/dist/schedule/runner.js +2 -2
  69. package/dist/schedule/runner.js.map +1 -1
  70. package/dist/semantic-import.d.ts.map +1 -1
  71. package/dist/semantic-import.js +28 -12
  72. package/dist/semantic-import.js.map +1 -1
  73. package/package.json +11 -11
  74. package/dist/assets/dql-notebook/assets/index-BbGJ8gZk.js +0 -3639
  75. package/dist/assets/dql-notebook/assets/index-C4oZK5xm.js +0 -3612
  76. package/dist/assets/dql-notebook/assets/index-CEMXuPm0.js +0 -3802
  77. package/dist/assets/dql-notebook/assets/index-CIMLd3Cb.js +0 -3289
  78. package/dist/assets/dql-notebook/assets/index-DISMWw19.js +0 -3780
  79. package/dist/assets/dql-notebook/assets/index-DPcxWQuS.js +0 -3859
  80. package/dist/assets/dql-notebook/assets/index-McpLeeN0.js +0 -3802
@@ -142,11 +142,55 @@ export function updateBlockStudioImportCandidate(projectRoot, importId, candidat
142
142
  next.owner = patch.owner;
143
143
  if (patch.tags !== undefined)
144
144
  next.tags = normalizeTags(patch.tags);
145
+ if (patch.terms !== undefined)
146
+ next.terms = normalizeStringList(patch.terms);
147
+ if (patch.pattern !== undefined)
148
+ next.pattern = normalizePattern(patch.pattern);
149
+ if (patch.grain !== undefined)
150
+ next.grain = patch.grain;
151
+ if (patch.entities !== undefined)
152
+ next.entities = normalizeStringList(patch.entities);
153
+ if (patch.outputs !== undefined)
154
+ next.outputs = normalizeStringList(patch.outputs);
155
+ if (patch.dimensions !== undefined)
156
+ next.dimensions = normalizeStringList(patch.dimensions);
157
+ if (patch.allowedFilters !== undefined)
158
+ next.allowedFilters = normalizeStringList(patch.allowedFilters);
159
+ if (patch.parameterPolicy !== undefined)
160
+ next.parameterPolicy = normalizeParameterPolicy(patch.parameterPolicy);
161
+ if (patch.filterBindings !== undefined)
162
+ next.filterBindings = normalizeFilterBindings(patch.filterBindings);
163
+ if (patch.sourceSystems !== undefined)
164
+ next.sourceSystems = normalizeStringList(patch.sourceSystems);
165
+ if (patch.replacementFor !== undefined)
166
+ next.replacementFor = normalizeStringList(patch.replacementFor);
167
+ if (patch.reviewCadence !== undefined)
168
+ next.reviewCadence = normalizeReviewCadence(patch.reviewCadence) || 'monthly';
169
+ if (patch.parameterDecisions !== undefined)
170
+ next.parameterDecisions = patch.parameterDecisions;
171
+ if (patch.similarityMatches !== undefined)
172
+ next.similarityMatches = patch.similarityMatches;
173
+ if (patch.recommendedAction !== undefined)
174
+ next.recommendedAction = patch.recommendedAction;
145
175
  if (patch.sql !== undefined)
146
176
  next.sql = patch.sql;
147
177
  if (patch.reviewStatus !== undefined)
148
178
  next.reviewStatus = patch.reviewStatus;
149
- if (patch.name || patch.domain || patch.description || patch.owner || patch.tags || patch.sql) {
179
+ if (patch.llmContext !== undefined)
180
+ next.llmContext = patch.llmContext;
181
+ if (patch.evidence !== undefined)
182
+ next.evidence = patch.evidence;
183
+ if (patch.draftSave !== undefined)
184
+ next.draftSave = patch.draftSave;
185
+ if (patch.generationMode !== undefined)
186
+ next.generationMode = patch.generationMode;
187
+ if (patch.generationProvider !== undefined)
188
+ next.generationProvider = patch.generationProvider;
189
+ if (patch.savedPath !== undefined)
190
+ next.savedPath = patch.savedPath;
191
+ if (patch.conversionNotes !== undefined)
192
+ next.conversionNotes = patch.conversionNotes;
193
+ if (patch.name || patch.domain || patch.description || patch.owner || patch.tags || patch.terms || patch.pattern || patch.grain || patch.entities || patch.outputs || patch.dimensions || patch.allowedFilters || patch.parameterPolicy || patch.filterBindings || patch.sourceSystems || patch.replacementFor || patch.reviewCadence || patch.parameterDecisions || patch.sql || patch.llmContext) {
150
194
  next.dqlSource = candidateToDqlSource(next);
151
195
  next.lineage = {
152
196
  ...next.lineage,
@@ -162,28 +206,561 @@ export function updateBlockStudioImportCandidate(projectRoot, importId, candidat
162
206
  }
163
207
  export function candidateToDqlSource(candidate) {
164
208
  const tags = normalizeTags(candidate.tags).map((tag) => dqlString(tag)).join(', ');
209
+ const terms = normalizeStringList(candidate.terms ?? []).slice(0, 16);
165
210
  const sql = candidate.sql.trim().replace(/"""/g, '\\"\\"\\"');
166
- return `block ${dqlString(candidate.name)} {
167
- status = "draft"
168
- domain = ${dqlString(sanitizeDomain(candidate.domain))}
169
- type = "custom"
170
- description = ${dqlString(candidate.description)}
171
- tags = [${tags}]
172
- owner = ${dqlString(candidate.owner)}
173
-
174
- query = """
175
- ${sql}
176
- """
177
-
178
- visualization {
179
- chart = "table"
180
- }
181
-
182
- tests {
183
- assert row_count > 0
184
- }
185
- }
186
- `;
211
+ const llmContext = candidate.llmContext?.trim();
212
+ const pattern = normalizePattern(candidate.pattern || inferSqlPattern(candidate.sql));
213
+ const grain = candidate.grain?.trim() || inferSqlGrain(candidate.sql);
214
+ const outputs = normalizeStringList(candidate.outputs?.length ? candidate.outputs : inferSqlOutputs(candidate.sql)).slice(0, 24);
215
+ const dimensions = normalizeStringList(candidate.dimensions?.length ? candidate.dimensions : inferSqlDimensions(candidate.sql, grain, outputs)).slice(0, 16);
216
+ const sourceSystems = normalizeStringList(candidate.sourceSystems?.length ? candidate.sourceSystems : inferSqlSourceSystems(candidate.sql)).slice(0, 12);
217
+ const entities = normalizeStringList(candidate.entities?.length ? candidate.entities : inferSqlEntities(grain, sourceSystems)).slice(0, 12);
218
+ const allowedFilters = normalizeStringList(candidate.allowedFilters?.length ? candidate.allowedFilters : inferSqlFilters(candidate.sql)).slice(0, 16);
219
+ const parameterDecisions = normalizeParameterDecisions(candidate.parameterDecisions ?? []);
220
+ const parameterPolicy = normalizeParameterPolicy(candidate.parameterPolicy?.length
221
+ ? candidate.parameterPolicy
222
+ : parameterDecisions.map((decision) => ({ name: decision.name, policy: decision.policy })));
223
+ const filterBindings = normalizeFilterBindings(candidate.filterBindings ?? []);
224
+ const reviewCadence = normalizeReviewCadence(candidate.reviewCadence || 'monthly');
225
+ const params = parameterDecisions.filter((decision) => decision.policy !== 'static');
226
+ const lines = [
227
+ `block ${dqlString(candidate.name)} {`,
228
+ ' status = "draft"',
229
+ ` domain = ${dqlString(sanitizeDomain(candidate.domain))}`,
230
+ ' type = "custom"',
231
+ ` description = ${dqlString(candidate.description)}`,
232
+ ` tags = [${tags}]`,
233
+ ` owner = ${dqlString(candidate.owner)}`,
234
+ ];
235
+ if (pattern)
236
+ lines.push(` pattern = ${dqlString(pattern)}`);
237
+ if (grain)
238
+ lines.push(` grain = ${dqlString(grain)}`);
239
+ if (entities.length > 0)
240
+ lines.push(` entities = [${entities.map(dqlString).join(', ')}]`);
241
+ if (outputs.length > 0)
242
+ lines.push(` outputs = [${outputs.map(dqlString).join(', ')}]`);
243
+ if (terms.length > 0)
244
+ lines.push(` terms = [${terms.map(dqlString).join(', ')}]`);
245
+ if (dimensions.length > 0)
246
+ lines.push(` dimensions = [${dimensions.map(dqlString).join(', ')}]`);
247
+ if (allowedFilters.length > 0)
248
+ lines.push(` allowedFilters = [${allowedFilters.map(dqlString).join(', ')}]`);
249
+ if (parameterPolicy.length > 0) {
250
+ lines.push(' parameterPolicy {');
251
+ lines.push(...parameterPolicy.map((entry) => ` ${entry.name} = ${dqlString(entry.policy)}`));
252
+ lines.push(' }');
253
+ }
254
+ if (filterBindings.length > 0) {
255
+ lines.push(' filterBindings {');
256
+ lines.push(...filterBindings.map((entry) => ` ${entry.filter} = ${dqlString(entry.binding)}`));
257
+ lines.push(' }');
258
+ }
259
+ if (sourceSystems.length > 0)
260
+ lines.push(` sourceSystems = [${sourceSystems.map(dqlString).join(', ')}]`);
261
+ if (candidate.replacementFor?.length)
262
+ lines.push(` replacementFor = [${candidate.replacementFor.map(dqlString).join(', ')}]`);
263
+ if (reviewCadence)
264
+ lines.push(` reviewCadence = ${dqlString(reviewCadence)}`);
265
+ if (llmContext)
266
+ lines.push(` llmContext = ${dqlString(llmContext)}`);
267
+ if (params.length > 0) {
268
+ lines.push('', ' params {');
269
+ lines.push(...params.map((param) => ` ${param.name} = ${formatDqlLiteral(param.value)}`));
270
+ lines.push(' }');
271
+ }
272
+ lines.push('', ' query = """', sql, ' """');
273
+ lines.push('', ' visualization {', ' chart = "table"', ' }');
274
+ lines.push('', ' tests {', ' assert row_count > 0', ' }', '}');
275
+ return `${lines.join('\n')}\n`;
276
+ }
277
+ function normalizeParameterPolicy(value) {
278
+ const seen = new Set();
279
+ const result = [];
280
+ for (const item of value) {
281
+ const name = normalizeIdentifier(item.name);
282
+ const policy = String(item.policy ?? '').trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_');
283
+ if (!name || !policy || seen.has(name))
284
+ continue;
285
+ seen.add(name);
286
+ result.push({ name, policy });
287
+ }
288
+ return result.slice(0, 24);
289
+ }
290
+ function normalizeFilterBindings(value) {
291
+ const seen = new Set();
292
+ const result = [];
293
+ for (const item of value) {
294
+ const filter = normalizeIdentifier(item.filter);
295
+ const binding = String(item.binding ?? '').trim();
296
+ if (!filter || !binding || seen.has(filter))
297
+ continue;
298
+ seen.add(filter);
299
+ result.push({ filter, binding });
300
+ }
301
+ return result.slice(0, 24);
302
+ }
303
+ function normalizeReviewCadence(value) {
304
+ const normalized = String(value ?? '').trim().toLowerCase().replace(/[^a-z0-9_ -]+/g, '').replace(/\s+/g, '_');
305
+ const allowed = new Set(['daily', 'weekly', 'biweekly', 'monthly', 'quarterly', 'semiannual', 'annual']);
306
+ return allowed.has(normalized) ? normalized : '';
307
+ }
308
+ function normalizeParameterDecisions(value) {
309
+ const seen = new Set();
310
+ const result = [];
311
+ for (const item of value) {
312
+ const name = normalizeIdentifier(item.name);
313
+ if (!name || seen.has(name))
314
+ continue;
315
+ seen.add(name);
316
+ result.push({
317
+ ...item,
318
+ name,
319
+ policy: normalizeParameterPolicyKind(item.policy),
320
+ reason: item.reason || 'Detected from imported SQL literal.',
321
+ confidence: Math.max(0, Math.min(1, Number(item.confidence) || 0.5)),
322
+ });
323
+ }
324
+ return result.slice(0, 24);
325
+ }
326
+ function normalizeParameterPolicyKind(value) {
327
+ const normalized = String(value || '').trim().toLowerCase();
328
+ if (normalized === 'dynamic'
329
+ || normalized === 'static'
330
+ || normalized === 'business'
331
+ || normalized === 'derived'
332
+ || normalized === 'optional'
333
+ || normalized === 'ambiguous_review_required')
334
+ return normalized;
335
+ return 'ambiguous_review_required';
336
+ }
337
+ function normalizeIdentifier(value) {
338
+ return String(value || '').trim().replace(/[^A-Za-z0-9_]+/g, '_').replace(/^_+|_+$/g, '').replace(/^([0-9])/, '_$1');
339
+ }
340
+ function formatDqlLiteral(value) {
341
+ if (Array.isArray(value))
342
+ return `[${value.map(formatDqlLiteral).join(', ')}]`;
343
+ if (typeof value === 'number' && Number.isFinite(value))
344
+ return String(value);
345
+ if (typeof value === 'boolean')
346
+ return value ? 'true' : 'false';
347
+ return dqlString(String(value));
348
+ }
349
+ export function parameterizeSqlForDqlImport(sql) {
350
+ let nextSql = sql;
351
+ const decisions = new Map();
352
+ const bindings = new Map();
353
+ const warnings = [];
354
+ const addDecision = (decision) => {
355
+ const name = normalizeIdentifier(decision.name);
356
+ if (!name || decisions.has(name))
357
+ return name;
358
+ decisions.set(name, { ...decision, name });
359
+ return name;
360
+ };
361
+ const addBinding = (filter, binding) => {
362
+ const normalized = normalizeIdentifier(filter);
363
+ if (!normalized || bindings.has(normalized))
364
+ return;
365
+ bindings.set(normalized, { filter: normalized, binding: binding.trim() });
366
+ };
367
+ const availableParamName = (preferred, fallback) => {
368
+ const normalized = normalizeIdentifier(preferred);
369
+ return normalized && !decisions.has(normalized) ? normalized : normalizeIdentifier(fallback);
370
+ };
371
+ const availableFilterName = (preferred, fallback) => {
372
+ const normalized = normalizeIdentifier(preferred);
373
+ return normalized && !bindings.has(normalized) ? normalized : normalizeIdentifier(fallback);
374
+ };
375
+ const placeholder = (name) => '${' + name + '}';
376
+ const yearLiterals = extractYearLiterals(nextSql);
377
+ const yearParamName = (year) => {
378
+ if (yearLiterals.length <= 1)
379
+ return 'season_year';
380
+ if (year === yearLiterals[0])
381
+ return 'season_start';
382
+ if (year === yearLiterals[yearLiterals.length - 1])
383
+ return 'season_end';
384
+ return `season_year_${year}`;
385
+ };
386
+ if (yearLiterals.length > 0) {
387
+ nextSql = nextSql.replace(/(extract\s*\(\s*year\s+from\s+([^)]+?)\s*\)\s*(?:>=|<=|<>|!=|=|>|<)\s*)([12][0-9]{3})/gi, (match, prefix, column, yearValue) => {
388
+ if (match.includes('${'))
389
+ return match;
390
+ const year = Number(yearValue);
391
+ const name = addDecision({
392
+ name: yearParamName(year),
393
+ policy: 'dynamic',
394
+ value: year,
395
+ valueType: 'year',
396
+ sourceExpression: `EXTRACT(YEAR FROM ${column.trim()})`,
397
+ reason: 'Year literal is user scope and should be reusable across seasons.',
398
+ confidence: 0.94,
399
+ });
400
+ addBinding(yearLiterals.length > 1 ? 'season_range' : 'season', `${column.trim()}.year`);
401
+ return `${prefix}${placeholder(name)}`;
402
+ });
403
+ nextSql = nextSql.replace(/(extract\s*\(\s*year\s+from\s+([^)]+?)\s*\)\s+in\s*\()([^)]+)(\))/gi, (match, prefix, column, list, suffix) => {
404
+ if (match.includes('${'))
405
+ return match;
406
+ const listYears = extractYearValuesFromList(list);
407
+ const normalizedColumn = column.trim();
408
+ if (listYears.length === 1) {
409
+ const name = addDecision({
410
+ name: 'season_year',
411
+ policy: 'dynamic',
412
+ value: listYears[0],
413
+ valueType: 'year',
414
+ sourceExpression: `EXTRACT(YEAR FROM ${normalizedColumn})`,
415
+ reason: 'Single-year list literal is user scope and should be reusable across seasons.',
416
+ confidence: 0.94,
417
+ });
418
+ addBinding('season', `${normalizedColumn}.year`);
419
+ return `EXTRACT(YEAR FROM ${normalizedColumn}) = ${placeholder(name)}`;
420
+ }
421
+ if (listYears.length > 1 && isContiguousYearRange(listYears)) {
422
+ const firstYear = listYears[0];
423
+ const lastYear = listYears[listYears.length - 1];
424
+ const startName = addDecision({
425
+ name: 'season_start',
426
+ policy: 'dynamic',
427
+ value: firstYear,
428
+ valueType: 'year',
429
+ sourceExpression: `EXTRACT(YEAR FROM ${normalizedColumn})`,
430
+ reason: 'Contiguous year list is treated as a reusable season range.',
431
+ confidence: 0.95,
432
+ });
433
+ const endName = addDecision({
434
+ name: 'season_end',
435
+ policy: 'dynamic',
436
+ value: lastYear,
437
+ valueType: 'year',
438
+ sourceExpression: `EXTRACT(YEAR FROM ${normalizedColumn})`,
439
+ reason: 'Contiguous year list is treated as a reusable season range.',
440
+ confidence: 0.95,
441
+ });
442
+ addBinding('season_range', `${normalizedColumn}.year`);
443
+ return `EXTRACT(YEAR FROM ${normalizedColumn}) BETWEEN ${placeholder(startName)} AND ${placeholder(endName)}`;
444
+ }
445
+ const replaced = list.replace(/\b([12][0-9]{3})\b/g, (_yearMatch, yearValue) => {
446
+ const year = Number(yearValue);
447
+ const name = addDecision({
448
+ name: `season_year_${year}`,
449
+ policy: 'dynamic',
450
+ value: year,
451
+ valueType: 'year',
452
+ sourceExpression: `EXTRACT(YEAR FROM ${normalizedColumn})`,
453
+ reason: 'Non-contiguous year list is user scope and should remain an explicit selected set.',
454
+ confidence: 0.94,
455
+ });
456
+ return placeholder(name);
457
+ });
458
+ addBinding('season_set', `${normalizedColumn}.year`);
459
+ return `${prefix}${replaced}${suffix}`;
460
+ });
461
+ }
462
+ nextSql = nextSql.replace(/\blimit\s+([0-9]+)\b/gi, (match, value) => {
463
+ if (match.includes('${'))
464
+ return match;
465
+ const topN = Number(value);
466
+ if (!Number.isFinite(topN) || topN <= 0 || topN > 10000)
467
+ return match;
468
+ const name = addDecision({
469
+ name: 'top_n',
470
+ policy: 'dynamic',
471
+ value: topN,
472
+ valueType: 'number',
473
+ sourceExpression: 'LIMIT',
474
+ reason: 'Top/bottom row count is presentation scope and should be reusable.',
475
+ confidence: 0.9,
476
+ });
477
+ return match.replace(value, placeholder(name));
478
+ });
479
+ nextSql = nextSql.replace(/\b([A-Za-z_][A-Za-z0-9_.]*)\s+between\s+(?:date\s+)?'([12][0-9]{3}-[01][0-9]-[0-3][0-9])'\s+and\s+(?:date\s+)?'([12][0-9]{3}-[01][0-9]-[0-3][0-9])'/gi, (match, column, startValue, endValue) => {
480
+ if (match.includes('${') || !isDateScopeColumn(column))
481
+ return match;
482
+ const columnName = lastIdentifierPart(column);
483
+ const startName = addDecision({
484
+ name: availableParamName('start_date', `${columnName}_start_date`),
485
+ policy: 'dynamic',
486
+ value: startValue,
487
+ valueType: 'date',
488
+ sourceExpression: column,
489
+ reason: 'Date range literal is runtime scope and should be controlled by app filters.',
490
+ confidence: 0.9,
491
+ });
492
+ const endName = addDecision({
493
+ name: availableParamName('end_date', `${columnName}_end_date`),
494
+ policy: 'dynamic',
495
+ value: endValue,
496
+ valueType: 'date',
497
+ sourceExpression: column,
498
+ reason: 'Date range literal is runtime scope and should be controlled by app filters.',
499
+ confidence: 0.9,
500
+ });
501
+ addBinding(availableFilterName('date_range', `${columnName}_range`), column);
502
+ return `${column} BETWEEN ${placeholder(startName)} AND ${placeholder(endName)}`;
503
+ });
504
+ nextSql = nextSql.replace(/\b([A-Za-z_][A-Za-z0-9_.]*)\s*(>=|<=|=|>|<)\s*'([12][0-9]{3}-[01][0-9]-[0-3][0-9])'/gi, (match, column, operator, value) => {
505
+ if (match.includes('${') || !isDateScopeColumn(column))
506
+ return match;
507
+ const name = operator === '>=' || operator === '>' ? 'start_date' : operator === '<=' || operator === '<' ? 'end_date' : `${lastIdentifierPart(column)}_date`;
508
+ const paramName = addDecision({
509
+ name,
510
+ policy: 'dynamic',
511
+ value,
512
+ valueType: 'date',
513
+ sourceExpression: column,
514
+ reason: 'Date literal is runtime scope and should be controlled by app filters.',
515
+ confidence: 0.88,
516
+ });
517
+ addBinding(name === 'start_date' || name === 'end_date' ? 'date_range' : name, column);
518
+ return `${column} ${operator} ${placeholder(paramName)}`;
519
+ });
520
+ nextSql = nextSql.replace(/\b([A-Za-z_][A-Za-z0-9_.]*)\s+in\s*\(([^)]*)\)/gi, (match, column, rawList) => {
521
+ if (match.includes('${') || /\bextract\s*\(/i.test(match))
522
+ return match;
523
+ const columnName = lastIdentifierPart(column);
524
+ if (!isRuntimeScopeColumn(columnName))
525
+ return match;
526
+ const values = parseSqlLiteralList(rawList);
527
+ if (!values || values.length === 0)
528
+ return match;
529
+ const name = addDecision({
530
+ name: `${columnName}_set`,
531
+ policy: 'dynamic',
532
+ value: values,
533
+ valueType: 'set',
534
+ sourceExpression: column,
535
+ reason: 'Selected-set literal is user scope and should stay reusable for one or many values.',
536
+ confidence: 0.86,
537
+ });
538
+ addBinding(`${columnName}_set`, column);
539
+ return `${column} IN (${placeholder(name)})`;
540
+ });
541
+ nextSql = nextSql.replace(/\b([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*('(?:''|[^'])*'|[0-9]+(?:\.[0-9]+)?)/gi, (match, column, rawValue) => {
542
+ if (match.includes('${') || /extract\s*\(/i.test(match))
543
+ return match;
544
+ const columnName = lastIdentifierPart(column);
545
+ if (!isRuntimeScopeColumn(columnName)) {
546
+ const literal = parseSqlLiteral(rawValue);
547
+ if (isBusinessRuleLiteral(columnName, literal)) {
548
+ const name = addDecision({
549
+ name: `${columnName}_rule`,
550
+ policy: 'static',
551
+ value: literal,
552
+ valueType: typeof literal === 'number' ? 'number' : 'string',
553
+ sourceExpression: column,
554
+ reason: 'Literal appears to define a fixed business rule, not runtime scope.',
555
+ confidence: 0.72,
556
+ });
557
+ warnings.push(`Kept ${columnName} as static business logic (${name}).`);
558
+ }
559
+ return match;
560
+ }
561
+ const literal = parseSqlLiteral(rawValue);
562
+ const name = addDecision({
563
+ name: columnName,
564
+ policy: 'dynamic',
565
+ value: literal,
566
+ valueType: typeof literal === 'number' ? 'number' : 'string',
567
+ sourceExpression: column,
568
+ reason: 'Filter literal is user scope and should be reusable.',
569
+ confidence: 0.82,
570
+ });
571
+ addBinding(columnName, column);
572
+ return `${column} = ${placeholder(name)}`;
573
+ });
574
+ const parameterDecisions = Array.from(decisions.values());
575
+ const parameterPolicy = parameterDecisions.map((decision) => ({ name: decision.name, policy: decision.policy }));
576
+ const filterBindings = Array.from(bindings.values());
577
+ const allowedFilters = Array.from(new Set([
578
+ ...filterBindings.map((binding) => binding.filter),
579
+ ...parameterDecisions.filter((decision) => decision.policy === 'dynamic').map((decision) => decision.name),
580
+ ])).slice(0, 16);
581
+ return { sql: nextSql, parameterDecisions, parameterPolicy, filterBindings, allowedFilters, warnings };
582
+ }
583
+ function extractYearLiterals(sql) {
584
+ const years = new Set();
585
+ const cleaned = stripSqlComments(sql);
586
+ const extractRegex = /extract\s*\(\s*year\s+from\s+[^)]+\)\s*(?:(?:>=|<=|<>|!=|=|>|<)\s*([12][0-9]{3})|\s+in\s*\(([^)]*)\))/gi;
587
+ let match;
588
+ while ((match = extractRegex.exec(cleaned))) {
589
+ for (const value of [match[1], ...(match[2]?.match(/[12][0-9]{3}/g) ?? [])]) {
590
+ if (value)
591
+ years.add(Number(value));
592
+ }
593
+ }
594
+ return Array.from(years).sort((a, b) => a - b);
595
+ }
596
+ function extractYearValuesFromList(list) {
597
+ const years = new Set();
598
+ for (const value of list.match(/\b[12][0-9]{3}\b/g) ?? []) {
599
+ years.add(Number(value));
600
+ }
601
+ return Array.from(years).sort((a, b) => a - b);
602
+ }
603
+ function isContiguousYearRange(years) {
604
+ if (years.length < 2)
605
+ return false;
606
+ for (let index = 1; index < years.length; index += 1) {
607
+ if (years[index] !== years[index - 1] + 1)
608
+ return false;
609
+ }
610
+ return true;
611
+ }
612
+ function lastIdentifierPart(column) {
613
+ return normalizeIdentifier(column.split('.').pop() ?? column).toLowerCase();
614
+ }
615
+ function stripSqlIdentifierQuotes(value) {
616
+ return value.replace(/["\[\]]/g, '').replaceAll('`', '');
617
+ }
618
+ function parseSqlLiteral(raw) {
619
+ if (/^'/.test(raw))
620
+ return raw.slice(1, -1).replace(/''/g, "'");
621
+ const numeric = Number(raw);
622
+ return Number.isFinite(numeric) ? numeric : raw;
623
+ }
624
+ function parseSqlLiteralList(rawList) {
625
+ const parts = splitSqlList(rawList).map((part) => part.trim()).filter(Boolean);
626
+ if (parts.length === 0)
627
+ return null;
628
+ const values = [];
629
+ for (const part of parts) {
630
+ if (!/^(?:'(?:''|[^'])*'|[0-9]+(?:\.[0-9]+)?)$/i.test(part))
631
+ return null;
632
+ values.push(parseSqlLiteral(part));
633
+ }
634
+ return values;
635
+ }
636
+ function isDateScopeColumn(column) {
637
+ return /(date|day|time|period|month|week)/i.test(column);
638
+ }
639
+ function isRuntimeScopeColumn(column) {
640
+ const normalized = normalizeIdentifier(column).toLowerCase();
641
+ return /(?:^|_)(player|team|customer|account|user|segment|region|product|position|market|channel|country|state|city|league|season|year)(?:_|$)/i.test(normalized)
642
+ || /(?:^|_)(id|key|name|code)$/i.test(normalized);
643
+ }
644
+ function isBusinessRuleLiteral(column, value) {
645
+ const text = String(value).toLowerCase();
646
+ return /(status|state|type|flag|is_|mode|category)/i.test(column)
647
+ && /^(active|approved|complete|completed|valid|regular|true|1|yes|current)$/i.test(text);
648
+ }
649
+ function inferSqlGrain(sql) {
650
+ const groupFields = extractGroupByFields(sql);
651
+ return groupFields[0] ?? '';
652
+ }
653
+ function inferSqlPattern(sql) {
654
+ const lower = stripSqlComments(sql).toLowerCase();
655
+ const groupFields = extractGroupByFields(sql);
656
+ if (/@metric\s*\(/i.test(sql))
657
+ return 'metric_wrapper';
658
+ if (/\bjoin\b/i.test(sql)) {
659
+ const systems = new Set(inferSqlSourceSystems(sql));
660
+ if (systems.size > 1)
661
+ return 'bridge';
662
+ }
663
+ if (hasRankingLimit(sql))
664
+ return 'ranking';
665
+ if (groupFields.some((field) => /\b(date|day|week|month|quarter|year|period|time)\b/i.test(field)))
666
+ return 'trend';
667
+ if (groupFields.length === 1 && /_id$|_key$/i.test(groupFields[0]))
668
+ return 'entity_rollup';
669
+ if (!/\b(sum|count|avg|min|max|median|percentile|rank)\s*\(/i.test(lower) && /\b(dim|profile|customer|account|player|product|user|entity)\b/i.test(lower)) {
670
+ return 'entity_profile';
671
+ }
672
+ return 'custom';
673
+ }
674
+ function hasRankingLimit(sql) {
675
+ return /\border\s+by\b[\s\S]*\blimit\s+(?:\d+|\$\{\s*[A-Za-z_][A-Za-z0-9_]*\s*\}|[:?][A-Za-z_][A-Za-z0-9_]*)/i.test(sql);
676
+ }
677
+ function inferSqlOutputs(sql) {
678
+ const selectMatch = sql.match(/\bselect\b([\s\S]+?)\bfrom\b/i);
679
+ if (!selectMatch)
680
+ return [];
681
+ return splitSqlList(selectMatch[1])
682
+ .map((expr) => {
683
+ const alias = expr.match(/\bas\s+([A-Za-z_][A-Za-z0-9_]*)\b/i)?.[1]
684
+ ?? expr.match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/)?.[1]
685
+ ?? '';
686
+ return stripSqlIdentifierQuotes(alias);
687
+ })
688
+ .filter(Boolean)
689
+ .filter((name) => !/^(from|where|group|order|limit)$/i.test(name));
690
+ }
691
+ function inferSqlFilters(sql) {
692
+ const filters = new Set();
693
+ const where = sql.match(/\bwhere\b([\s\S]+?)(?:\bgroup\s+by\b|\border\s+by\b|\blimit\b|$)/i)?.[1] ?? '';
694
+ const addFilter = (value) => {
695
+ const name = stripSqlIdentifierQuotes(value?.split('.').pop() ?? '');
696
+ if (name && !/^(and|or|not|null|year|month|day)$/i.test(name))
697
+ filters.add(name);
698
+ };
699
+ const extractRegex = /\bextract\s*\([^)]*\bfrom\s+([A-Za-z_][A-Za-z0-9_.]*)\s*\)\s*(?:=|<>|!=|>|<|>=|<=|\bin\b|\blike\b)/gi;
700
+ const isNullRegex = /\b([A-Za-z_][A-Za-z0-9_.]*)\s+is\s+(?:not\s+)?null\b/gi;
701
+ const regex = /\b([A-Za-z_][A-Za-z0-9_.]*)\s*(?:=|<>|!=|>|<|>=|<=|\bin\b|\blike\b)/gi;
702
+ let match;
703
+ while ((match = extractRegex.exec(where)))
704
+ addFilter(match[1]);
705
+ while ((match = isNullRegex.exec(where)))
706
+ addFilter(match[1]);
707
+ while ((match = regex.exec(where))) {
708
+ addFilter(match[1]);
709
+ }
710
+ return [...filters];
711
+ }
712
+ function inferSqlSourceSystems(sql) {
713
+ return extractSourceTables(sql)
714
+ .map((table) => table.split('.').filter(Boolean).slice(-2, -1)[0] ?? table.split('.').filter(Boolean)[0])
715
+ .filter(Boolean)
716
+ .map((value) => businessToken(value))
717
+ .filter(Boolean);
718
+ }
719
+ function inferSqlEntities(grain, sourceSystems) {
720
+ const entity = grain && /_id$|_key$/i.test(grain) ? businessEntityFromIdentifier(grain) : '';
721
+ return Array.from(new Set([entity, ...sourceSystems.map(businessEntityFromIdentifier)].filter(Boolean)));
722
+ }
723
+ function inferSqlDimensions(sql, grain, outputs) {
724
+ const outputSet = new Set(outputs.map((output) => output.toLowerCase()));
725
+ return extractGroupByFields(sql)
726
+ .filter((field) => field !== grain)
727
+ .filter((field) => !outputSet.has(field.toLowerCase()))
728
+ .filter((field) => !/\b(total|count|sum|avg|average|min|max|rate|pct|percent|amount|revenue|points?|score)\b/i.test(field))
729
+ .slice(0, 16);
730
+ }
731
+ function extractGroupByFields(sql) {
732
+ const match = sql.match(/\bgroup\s+by\b([\s\S]+?)(?:\border\s+by\b|\blimit\b|\bqualify\b|\bhaving\b|$)/i);
733
+ if (!match)
734
+ return [];
735
+ return splitSqlList(match[1])
736
+ .map((item) => stripSqlIdentifierQuotes(item).trim())
737
+ .filter((item) => item && !/^\d+$/.test(item))
738
+ .map((item) => item.split('.').pop() ?? item)
739
+ .slice(0, 4);
740
+ }
741
+ function splitSqlList(value) {
742
+ const parts = [];
743
+ let start = 0;
744
+ let depth = 0;
745
+ let single = false;
746
+ let double = false;
747
+ for (let i = 0; i < value.length; i += 1) {
748
+ const char = value[i];
749
+ if (!double && char === "'" && value[i - 1] !== '\\')
750
+ single = !single;
751
+ else if (!single && char === '"' && value[i - 1] !== '\\')
752
+ double = !double;
753
+ else if (!single && !double && char === '(')
754
+ depth += 1;
755
+ else if (!single && !double && char === ')' && depth > 0)
756
+ depth -= 1;
757
+ else if (!single && !double && depth === 0 && char === ',') {
758
+ parts.push(value.slice(start, i));
759
+ start = i + 1;
760
+ }
761
+ }
762
+ parts.push(value.slice(start));
763
+ return parts.map((part) => part.trim()).filter(Boolean);
187
764
  }
188
765
  function collectSqlStatements(sources) {
189
766
  const statements = [];
@@ -210,11 +787,12 @@ function buildSqlCandidate(options) {
210
787
  const statement = options.statement;
211
788
  const sourcePath = displayPath(options.projectRoot, statement.sourcePath);
212
789
  const metadata = extractStatementMetadata(statement.sql);
213
- const baseName = metadata.name || basename(statement.sourcePath, extname(statement.sourcePath));
790
+ const sourceTables = extractSourceTables(statement.sql);
791
+ const inferred = metadata.name ? null : inferStatementBusinessMetadata(statement.sql, sourcePath, sourceTables);
792
+ const baseName = metadata.name || inferred?.name || basename(statement.sourcePath, extname(statement.sourcePath));
214
793
  const name = statement.totalStatements > 1 && !metadata.name
215
794
  ? `${baseName} ${statement.statementIndex}`
216
795
  : baseName;
217
- const sourceTables = extractSourceTables(statement.sql);
218
796
  const parameters = extractSqlParameters(statement.sql);
219
797
  const warnings = [
220
798
  ...(parameters.length > 0 ? [`Contains parameters: ${parameters.join(', ')}`] : []),
@@ -234,9 +812,9 @@ function buildSqlCandidate(options) {
234
812
  sourcePath,
235
813
  name: titleizeName(name),
236
814
  domain: metadata.domain ? sanitizeDomain(metadata.domain) : options.defaults.domain,
237
- description: metadata.description || `Imported from ${sourcePath}`,
815
+ description: metadata.description || inferred?.description || `Imported from ${sourcePath}`,
238
816
  owner: options.defaults.owner,
239
- tags: normalizeTags([...options.defaults.tags, ...metadata.tags]),
817
+ tags: normalizeTags([...options.defaults.tags, ...metadata.tags, ...(inferred?.tags ?? [])]),
240
818
  sql: statement.sql.trim(),
241
819
  dqlSource: '',
242
820
  validation: null,
@@ -255,6 +833,7 @@ function buildSqlCandidate(options) {
255
833
  'Optional AI assist is review-gated and only receives this candidate context.',
256
834
  ],
257
835
  aiAssistance: [],
836
+ reviewCadence: 'monthly',
258
837
  reviewStatus: 'draft',
259
838
  };
260
839
  candidate.dqlSource = candidateToDqlSource(candidate);
@@ -407,9 +986,154 @@ function extractStatementMetadata(sql) {
407
986
  const firstComment = leading.match(/^\s*--\s*(?!name\s*:|block\s*:)(.+)$/im)?.[1]?.trim() ?? '';
408
987
  return { name, description: firstComment, domain, tags };
409
988
  }
989
+ function inferStatementBusinessMetadata(sql, sourcePath, sourceTables) {
990
+ const expressions = extractSelectExpressions(sql);
991
+ const aggregateAliases = expressions.filter(isAggregateExpression).map(extractExpressionAlias).filter(Boolean);
992
+ const dimensionAliases = expressions.filter((expression) => !isAggregateExpression(expression)).map(extractExpressionAlias).filter(Boolean);
993
+ const orderByAliases = extractOrderByAliases(sql);
994
+ const metric = aggregateAliases[0] || orderByAliases.find((alias) => /total|sum|count|avg|score|point|revenue|amount|sales|games?/i.test(alias)) || aggregateAliases[0] || '';
995
+ const dimension = dimensionAliases.find((alias) => !/^row_?number$/i.test(alias)) || '';
996
+ const years = extractYearFilters(sql);
997
+ const tableLabel = sourceTables[0] || sourcePath;
998
+ const tableEntity = businessEntityFromIdentifier(tableLabel.split('.').pop() || tableLabel);
999
+ const metricLabel = metric ? titleizeName(metric) : '';
1000
+ const pluralDimension = dimension ? pluralizeBusinessEntity(businessEntityFromIdentifier(dimension)) : pluralizeBusinessEntity(tableEntity);
1001
+ const yearLabel = years.length === 1 ? years[0] : years.length > 1 ? years.join(' ') : '';
1002
+ const yearClause = years.length === 1 ? ` for ${years[0]}` : years.length > 1 ? ` for ${years.join(' and ')}` : '';
1003
+ let name = '';
1004
+ if (metricLabel && dimension) {
1005
+ name = `Top ${pluralDimension} By ${metricLabel}${yearLabel ? ` ${yearLabel}` : ''}`;
1006
+ }
1007
+ else if (metricLabel) {
1008
+ name = `${metricLabel}${yearLabel ? ` ${yearLabel}` : ''}`;
1009
+ }
1010
+ else if (dimension) {
1011
+ name = `${pluralDimension} Detail${yearLabel ? ` ${yearLabel}` : ''}`;
1012
+ }
1013
+ const selectedMeasures = aggregateAliases.map(titleizeName).filter((label) => label && label !== metricLabel);
1014
+ let description = '';
1015
+ if (metricLabel && dimension) {
1016
+ description = `Ranks ${pluralDimension.toLowerCase()} by ${metricLabel.toLowerCase()}${yearClause} using ${sourceTables.join(', ') || sourcePath}.`;
1017
+ }
1018
+ else if (metricLabel) {
1019
+ description = `Calculates ${metricLabel.toLowerCase()}${yearClause} using ${sourceTables.join(', ') || sourcePath}.`;
1020
+ }
1021
+ else if (dimension) {
1022
+ description = `Lists ${pluralDimension.toLowerCase()}${yearClause} from ${sourceTables.join(', ') || sourcePath}.`;
1023
+ }
1024
+ if (description && selectedMeasures.length > 0) {
1025
+ description = `${description.replace(/\.$/, '')}, including ${selectedMeasures.map((item) => item.toLowerCase()).join(', ')}.`;
1026
+ }
1027
+ const tags = [
1028
+ ...sourceTables.flatMap((table) => table.split('.').slice(-2).map(businessToken)),
1029
+ metric,
1030
+ dimension,
1031
+ ...years,
1032
+ ].flatMap((item) => item.split(/[_\s.-]+/)).map(businessToken).filter(Boolean);
1033
+ return {
1034
+ name: name || '',
1035
+ description,
1036
+ tags,
1037
+ };
1038
+ }
1039
+ function extractSelectExpressions(sql) {
1040
+ const cleaned = stripSqlComments(sql);
1041
+ const match = cleaned.match(/\bselect\b([\s\S]+?)\bfrom\b/i);
1042
+ if (!match)
1043
+ return [];
1044
+ return splitTopLevelCommas(match[1])
1045
+ .map((expression) => expression.trim())
1046
+ .filter((expression) => expression && expression !== '*');
1047
+ }
1048
+ function splitTopLevelCommas(value) {
1049
+ const parts = [];
1050
+ let start = 0;
1051
+ let depth = 0;
1052
+ let single = false;
1053
+ let double = false;
1054
+ for (let i = 0; i < value.length; i += 1) {
1055
+ const char = value[i];
1056
+ if (!double && char === "'" && value[i - 1] !== '\\')
1057
+ single = !single;
1058
+ else if (!single && char === '"' && value[i - 1] !== '\\')
1059
+ double = !double;
1060
+ else if (!single && !double && char === '(')
1061
+ depth += 1;
1062
+ else if (!single && !double && char === ')' && depth > 0)
1063
+ depth -= 1;
1064
+ else if (!single && !double && depth === 0 && char === ',') {
1065
+ parts.push(value.slice(start, i));
1066
+ start = i + 1;
1067
+ }
1068
+ }
1069
+ parts.push(value.slice(start));
1070
+ return parts;
1071
+ }
1072
+ function isAggregateExpression(expression) {
1073
+ return /\b(sum|count|avg|min|max|median|percentile_cont|percentile_disc)\s*\(/i.test(expression);
1074
+ }
1075
+ function extractExpressionAlias(expression) {
1076
+ const asMatch = expression.match(/\bas\s+[`"[]?([A-Za-z_][A-Za-z0-9_$]*)[`"\]]?\s*$/i);
1077
+ if (asMatch)
1078
+ return asMatch[1];
1079
+ const trailing = expression.match(/\s+[`"[]?([A-Za-z_][A-Za-z0-9_$]*)[`"\]]?\s*$/);
1080
+ if (trailing && !/[).]$/.test(trailing[1]))
1081
+ return trailing[1];
1082
+ const simple = expression.match(/(?:^|\.)[`"[]?([A-Za-z_][A-Za-z0-9_$]*)[`"\]]?\s*$/);
1083
+ return simple?.[1] ?? '';
1084
+ }
1085
+ function extractOrderByAliases(sql) {
1086
+ const cleaned = stripSqlComments(sql);
1087
+ const match = cleaned.match(/\border\s+by\b([\s\S]+?)(?:\blimit\b|\bfetch\b|\boffset\b|$)/i);
1088
+ if (!match)
1089
+ return [];
1090
+ return splitTopLevelCommas(match[1])
1091
+ .map((item) => item.replace(/\b(asc|desc|nulls\s+first|nulls\s+last)\b/gi, '').trim())
1092
+ .map(extractExpressionAlias)
1093
+ .filter(Boolean);
1094
+ }
1095
+ function extractYearFilters(sql) {
1096
+ const years = new Set();
1097
+ const cleaned = stripSqlComments(sql);
1098
+ let match;
1099
+ const extractYear = /extract\s*\(\s*year\s+from\s+[^)]+\)\s*(?:=\s*([12][0-9]{3})|in\s*\(([^)]*)\))/gi;
1100
+ while ((match = extractYear.exec(cleaned))) {
1101
+ const raw = match[1] || match[2] || '';
1102
+ for (const year of raw.match(/[12][0-9]{3}/g) ?? [])
1103
+ years.add(year);
1104
+ }
1105
+ const namedYear = /\b(?:year|season)\b\s*(?:=|in\s*\()\s*([^)\s]+(?:\s*,\s*[^)\s]+)*)/gi;
1106
+ while ((match = namedYear.exec(cleaned))) {
1107
+ for (const year of match[1].match(/[12][0-9]{3}/g) ?? [])
1108
+ years.add(year);
1109
+ }
1110
+ return Array.from(years).sort();
1111
+ }
1112
+ function businessEntityFromIdentifier(identifier) {
1113
+ const clean = identifier
1114
+ .replace(/^(dim|fact|fct|stg|src|int)_/i, '')
1115
+ .replace(/_(id|key|name|code)$/i, '')
1116
+ .replace(/s$/i, '');
1117
+ return titleizeName(clean) || 'Records';
1118
+ }
1119
+ function pluralizeBusinessEntity(entity) {
1120
+ if (/s$/i.test(entity))
1121
+ return entity;
1122
+ if (/y$/i.test(entity))
1123
+ return `${entity.slice(0, -1)}ies`;
1124
+ return `${entity}s`;
1125
+ }
1126
+ function businessToken(value) {
1127
+ return value
1128
+ .trim()
1129
+ .toLowerCase()
1130
+ .replace(/^(dim|fact|fct|stg|src|int)_/, '')
1131
+ .replace(/[^a-z0-9_-]+/g, '-')
1132
+ .replace(/^-+|-+$/g, '');
1133
+ }
410
1134
  function extractSourceTables(sql) {
411
1135
  const tables = new Set();
412
- const cleaned = stripSqlComments(sql);
1136
+ const cleaned = stripSqlComments(sql).replace(/extract\s*\(\s*\w+\s+from\s+[^)]+\)/gi, 'EXTRACT_VALUE');
413
1137
  const cteNames = extractCteNames(cleaned);
414
1138
  const regex = /\b(?:from|join|update|into)\s+([`"[]?[A-Za-z0-9_./:-]+(?:\.[A-Za-z0-9_./:-]+)*[`"\]]?)/gi;
415
1139
  let match;
@@ -447,6 +1171,9 @@ function extractSqlParameters(sql) {
447
1171
  const handlebars = /\{\{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*\}\}/g;
448
1172
  while ((match = handlebars.exec(cleaned)))
449
1173
  params.add(match[1]);
1174
+ const dqlTemplate = /\$\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g;
1175
+ while ((match = dqlTemplate.exec(cleaned)))
1176
+ params.add(match[1]);
450
1177
  const colon = /(^|[^:]):([A-Za-z_][A-Za-z0-9_]*)\b/g;
451
1178
  while ((match = colon.exec(cleaned)))
452
1179
  params.add(match[2]);
@@ -575,6 +1302,24 @@ function sanitizeDomain(domain) {
575
1302
  function normalizeTags(tags) {
576
1303
  return Array.from(new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean)));
577
1304
  }
1305
+ function normalizePattern(value) {
1306
+ const normalized = (value ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
1307
+ const allowed = new Set([
1308
+ 'metric_wrapper',
1309
+ 'entity_profile',
1310
+ 'entity_rollup',
1311
+ 'ranking',
1312
+ 'trend',
1313
+ 'bridge',
1314
+ 'drilldown',
1315
+ 'replacement',
1316
+ 'custom',
1317
+ ]);
1318
+ return allowed.has(normalized) ? normalized : '';
1319
+ }
1320
+ function normalizeStringList(values) {
1321
+ return Array.from(new Set((values ?? []).map((value) => value.trim()).filter(Boolean)));
1322
+ }
578
1323
  function titleizeName(name) {
579
1324
  return name
580
1325
  .replace(/\.[^.]+$/, '')