@duckcodeailabs/dql-cli 1.6.34 → 1.7.1

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 (79) hide show
  1. package/dist/apps-api.d.ts +20 -3
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +156 -85
  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 +7 -0
  8. package/dist/args.js.map +1 -1
  9. package/dist/assets/dql-notebook/assets/index-4DbeLWHA.js +5876 -0
  10. package/dist/assets/dql-notebook/assets/index-dSyhu_eU.css +1 -0
  11. package/dist/assets/dql-notebook/index.html +2 -2
  12. package/dist/block-invocation.d.ts +31 -0
  13. package/dist/block-invocation.d.ts.map +1 -0
  14. package/dist/block-invocation.js +82 -0
  15. package/dist/block-invocation.js.map +1 -0
  16. package/dist/commands/agent.d.ts +1 -1
  17. package/dist/commands/agent.d.ts.map +1 -1
  18. package/dist/commands/agent.js +12 -3
  19. package/dist/commands/agent.js.map +1 -1
  20. package/dist/commands/app.d.ts +1 -2
  21. package/dist/commands/app.d.ts.map +1 -1
  22. package/dist/commands/app.js +6 -9
  23. package/dist/commands/app.js.map +1 -1
  24. package/dist/commands/compile.js +1 -1
  25. package/dist/commands/compile.js.map +1 -1
  26. package/dist/commands/init.d.ts.map +1 -1
  27. package/dist/commands/init.js +44 -86
  28. package/dist/commands/init.js.map +1 -1
  29. package/dist/commands/migrate.d.ts +87 -0
  30. package/dist/commands/migrate.d.ts.map +1 -1
  31. package/dist/commands/migrate.js +812 -3
  32. package/dist/commands/migrate.js.map +1 -1
  33. package/dist/commands/model.d.ts +4 -0
  34. package/dist/commands/model.d.ts.map +1 -0
  35. package/dist/commands/model.js +219 -0
  36. package/dist/commands/model.js.map +1 -0
  37. package/dist/commands/new.d.ts.map +1 -1
  38. package/dist/commands/new.js +28 -4
  39. package/dist/commands/new.js.map +1 -1
  40. package/dist/commands/parse.d.ts.map +1 -1
  41. package/dist/commands/parse.js +32 -0
  42. package/dist/commands/parse.js.map +1 -1
  43. package/dist/commands/sync.d.ts.map +1 -1
  44. package/dist/commands/sync.js +23 -7
  45. package/dist/commands/sync.js.map +1 -1
  46. package/dist/index.js +19 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/llm/answer-loop-tools.d.ts +1 -1
  49. package/dist/llm/answer-loop-tools.d.ts.map +1 -1
  50. package/dist/llm/answer-loop-tools.js +210 -10
  51. package/dist/llm/answer-loop-tools.js.map +1 -1
  52. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  53. package/dist/llm/providers/dql-agent-provider.js +30 -18
  54. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  55. package/dist/llm/tools.d.ts.map +1 -1
  56. package/dist/llm/tools.js +3 -1
  57. package/dist/llm/tools.js.map +1 -1
  58. package/dist/llm/types.d.ts +12 -2
  59. package/dist/llm/types.d.ts.map +1 -1
  60. package/dist/local-runtime.d.ts +95 -7
  61. package/dist/local-runtime.d.ts.map +1 -1
  62. package/dist/local-runtime.js +3365 -172
  63. package/dist/local-runtime.js.map +1 -1
  64. package/dist/metricflow.d.ts +2 -0
  65. package/dist/metricflow.d.ts.map +1 -1
  66. package/dist/metricflow.js +10 -0
  67. package/dist/metricflow.js.map +1 -1
  68. package/dist/mixed-source-sql.d.ts +53 -0
  69. package/dist/mixed-source-sql.d.ts.map +1 -0
  70. package/dist/mixed-source-sql.js +311 -0
  71. package/dist/mixed-source-sql.js.map +1 -0
  72. package/dist/notebook-datasets.d.ts +120 -0
  73. package/dist/notebook-datasets.d.ts.map +1 -0
  74. package/dist/notebook-datasets.js +484 -0
  75. package/dist/notebook-datasets.js.map +1 -0
  76. package/dist/package.json +10 -10
  77. package/package.json +10 -10
  78. package/dist/assets/dql-notebook/assets/index-CYQrnwxa.css +0 -1
  79. package/dist/assets/dql-notebook/assets/index-GofggEGB.js +0 -5777
@@ -1,6 +1,8 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, join, relative, resolve } from 'node:path';
3
- import { canonicalize, canonicalizeNotebook } from '@duckcodeailabs/dql-core';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
+ import * as yaml from 'js-yaml';
5
+ import { canonicalize, canonicalizeNotebook, loadDomainPackageRegistry, NodeKind, Parser, blockParameterDefinitions, applyDataLexMigration, resolveBlockParameterValues, planDataLexMigration, resolveDbtManifestPath, } from '@duckcodeailabs/dql-core';
4
6
  import { findProjectRoot } from '../local-runtime.js';
5
7
  import { runImport } from './import.js';
6
8
  /**
@@ -40,6 +42,18 @@ export async function runMigrate(file, flags) {
40
42
  await runLayoutMigrate(flags);
41
43
  return;
42
44
  }
45
+ if (file === 'parameters') {
46
+ await runParameterMigrateCheck(flags);
47
+ return;
48
+ }
49
+ if (file === 'datalex') {
50
+ await runDataLexManifestMigration(flags);
51
+ return;
52
+ }
53
+ if (file === 'modeling') {
54
+ await runModelingMigration(flags);
55
+ return;
56
+ }
43
57
  // file is used as the source type for migration
44
58
  const source = file;
45
59
  const validSources = ['looker', 'tableau', 'dbt', 'metabase', 'raw-sql'];
@@ -48,6 +62,9 @@ export async function runMigrate(file, flags) {
48
62
  console.error(` Valid sources: ${validSources.join(', ')}`);
49
63
  console.error(` Or: "format" to upgrade .dql/.dqlnb files to the canonical on-disk format`);
50
64
  console.error(` Or: "layout --to domain-first --dry-run" to preview enterprise domain layout moves`);
65
+ console.error(` Or: "parameters --check" to audit legacy block parameter contracts`);
66
+ console.error(` Or: "datalex --input <datalex-manifest.json> [--apply]" for a dbt-first DQL v3 migration`);
67
+ console.error(` Or: "modeling --to dbt-first --dry-run|--apply" for explicit DQL 2.0 adoption`);
51
68
  console.error('');
52
69
  process.exit(1);
53
70
  }
@@ -123,6 +140,798 @@ export async function runMigrate(file, flags) {
123
140
  console.log(' 4. Commit and push for certification');
124
141
  console.log('');
125
142
  }
143
+ /** MIG-001/MIG-002: explicit, previewable adoption of the canonical dbt-first layout. */
144
+ export async function runModelingMigration(flags) {
145
+ if (flags.to !== 'dbt-first' || (flags.apply === true && flags.dryRun === true)) {
146
+ throw new Error('Usage: dql migrate modeling --to dbt-first --dry-run|--apply');
147
+ }
148
+ const projectRoot = findProjectRoot(resolve(flags.input || process.cwd()));
149
+ const plan = planModelingMigration(projectRoot);
150
+ const apply = flags.apply === true;
151
+ let applied;
152
+ if (apply) {
153
+ if (plan.report.ambiguities.length > 0) {
154
+ plan.report.status = 'blocked';
155
+ process.exitCode = 1;
156
+ }
157
+ else if (plan.report.status !== 'noop') {
158
+ applied = applyModelingMigration(projectRoot, plan);
159
+ plan.report.mode = 'applied';
160
+ plan.report.status = 'applied';
161
+ plan.report.written = applied.written;
162
+ plan.report.removed = applied.removed;
163
+ }
164
+ else {
165
+ plan.report.mode = 'applied';
166
+ }
167
+ }
168
+ if (flags.format === 'json') {
169
+ console.log(JSON.stringify(plan.report, null, 2));
170
+ return;
171
+ }
172
+ console.log(`\n DQL modeling migration to dbt-first (${apply ? 'apply' : 'dry run'})`);
173
+ console.log(' ─────────────────────────────');
174
+ console.log(` Project: ${projectRoot}`);
175
+ console.log(` Fingerprint: ${plan.report.fingerprint}`);
176
+ console.log(` Status: ${plan.report.status}`);
177
+ console.log(` Config: ${plan.report.configChanges.length}`);
178
+ console.log(` Models: ${plan.report.modelingConsolidations.length}`);
179
+ console.log(` Products: ${plan.report.productMoves.length}`);
180
+ console.log(` Ambiguities: ${plan.report.ambiguities.length}`);
181
+ console.log(` Explicit loss: ${plan.report.losses.length}`);
182
+ for (const issue of plan.report.ambiguities)
183
+ console.log(` ✗ ${issue.code} ${issue.path}: ${issue.detail}`);
184
+ for (const loss of plan.report.losses)
185
+ console.log(` ! ${loss.code} ${loss.path}: ${loss.detail}`);
186
+ if (!apply && plan.report.status === 'ready')
187
+ console.log(' Review this fingerprint, then re-run with --apply.');
188
+ console.log('');
189
+ }
190
+ /** Build a deterministic plan without writing. Exported for commit-scoped migration tests. */
191
+ export function planModelingMigration(projectRootInput) {
192
+ const projectRoot = resolve(projectRootInput);
193
+ const writes = [];
194
+ const deletes = [];
195
+ const report = {
196
+ target: 'dbt-first',
197
+ mode: 'dry-run',
198
+ status: 'ready',
199
+ fingerprint: '',
200
+ configChanges: [],
201
+ modelingConsolidations: [],
202
+ productMoves: [],
203
+ qualifiedIdentityRewrites: [],
204
+ ambiguities: [],
205
+ losses: [],
206
+ written: [],
207
+ removed: [],
208
+ };
209
+ const configPath = join(projectRoot, 'dql.config.json');
210
+ let config = {};
211
+ try {
212
+ if (existsSync(configPath)) {
213
+ const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
214
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
215
+ throw new Error('expected a JSON object');
216
+ config = parsed;
217
+ }
218
+ }
219
+ catch (error) {
220
+ report.ambiguities.push({ code: 'INVALID_SOURCE', path: 'dql.config.json', detail: errorMessage(error) });
221
+ }
222
+ const nextConfig = structuredClone(config);
223
+ if (nextConfig.manifestVersion !== 3) {
224
+ nextConfig.manifestVersion = 3;
225
+ report.configChanges.push('manifestVersion: 3');
226
+ }
227
+ const existingModeling = jsonRecord(nextConfig.modeling);
228
+ if (nextConfig.modeling !== undefined && (typeof nextConfig.modeling !== 'object' || nextConfig.modeling === null || Array.isArray(nextConfig.modeling))) {
229
+ report.ambiguities.push({
230
+ code: 'INVALID_SOURCE',
231
+ path: 'dql.config.json.modeling',
232
+ detail: 'Existing modeling configuration is not an object and cannot be preserved safely.',
233
+ });
234
+ }
235
+ if (existingModeling.mode !== 'dbt-first') {
236
+ nextConfig.modeling = { ...existingModeling, mode: 'dbt-first' };
237
+ report.configChanges.push('modeling.mode: dbt-first');
238
+ }
239
+ const nextConfigSource = `${JSON.stringify(nextConfig, null, 2)}\n`;
240
+ if (!existsSync(configPath) || readFileSync(configPath, 'utf8') !== nextConfigSource) {
241
+ writes.push({ kind: 'file', target: 'dql.config.json', content: nextConfigSource });
242
+ }
243
+ const dbtManifestPath = resolveDbtManifestPath(projectRoot);
244
+ let dbtIndex;
245
+ if (!dbtManifestPath || !existsSync(dbtManifestPath)) {
246
+ report.ambiguities.push({
247
+ code: 'DBT_MANIFEST_MISSING',
248
+ path: 'dql.config.json',
249
+ detail: 'dbt-first migration requires a readable dbt manifest.json; run dbt parse/compile or configure dbt.projectDir.',
250
+ });
251
+ }
252
+ else {
253
+ try {
254
+ dbtIndex = readDbtBindingIndex(dbtManifestPath);
255
+ }
256
+ catch (error) {
257
+ report.ambiguities.push({ code: 'INVALID_SOURCE', path: displayPath(projectRoot, dbtManifestPath), detail: errorMessage(error) });
258
+ }
259
+ }
260
+ const registry = loadDomainPackageRegistry(projectRoot);
261
+ for (const diagnostic of registry.diagnostics.filter((item) => item.severity === 'error')) {
262
+ report.ambiguities.push({
263
+ code: 'DOMAIN_UNRESOLVED',
264
+ path: diagnostic.filePath ?? 'domains',
265
+ detail: diagnostic.message,
266
+ });
267
+ }
268
+ for (const pkg of registry.values()) {
269
+ planModelingPackage(projectRoot, pkg.id, pkg.root, dbtIndex, report, writes, deletes);
270
+ }
271
+ planLegacyProducts(projectRoot, registry, report, writes, deletes);
272
+ report.ambiguities.sort(compareIssue);
273
+ report.losses.sort((a, b) => `${a.path}:${a.code}:${a.detail}`.localeCompare(`${b.path}:${b.code}:${b.detail}`));
274
+ report.productMoves.sort((a, b) => `${a.source}:${a.target}`.localeCompare(`${b.source}:${b.target}`));
275
+ report.modelingConsolidations.sort((a, b) => a.domain.localeCompare(b.domain));
276
+ report.qualifiedIdentityRewrites.sort((a, b) => a.qualifiedId.localeCompare(b.qualifiedId));
277
+ report.configChanges.sort();
278
+ writes.sort((a, b) => a.target.localeCompare(b.target));
279
+ const uniqueDeletes = [...new Set(deletes)].sort();
280
+ report.fingerprint = fingerprintMigrationState(projectRoot, dbtManifestPath);
281
+ if (report.ambiguities.length > 0)
282
+ report.status = 'blocked';
283
+ else if (writes.length === 0 && uniqueDeletes.length === 0)
284
+ report.status = 'noop';
285
+ return { report, writes, deletes: uniqueDeletes };
286
+ }
287
+ function planModelingPackage(projectRoot, domain, packageRoot, dbtIndex, report, writes, deletes) {
288
+ const modelingDir = join(packageRoot, 'modeling');
289
+ if (!existsSync(modelingDir))
290
+ return;
291
+ const sources = readdirSync(modelingDir, { withFileTypes: true })
292
+ .filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name))
293
+ .map((entry) => join(modelingDir, entry.name))
294
+ .sort();
295
+ if (sources.length === 0)
296
+ return;
297
+ const targetAbs = join(modelingDir, 'model.dql.yaml');
298
+ const targetRel = slash(relative(projectRoot, targetAbs));
299
+ const merged = {};
300
+ const identities = new Map();
301
+ let valid = true;
302
+ for (const sourcePath of sources) {
303
+ const sourceRel = slash(relative(projectRoot, sourcePath));
304
+ let document;
305
+ const source = readFileSync(sourcePath, 'utf8');
306
+ try {
307
+ const parsed = yaml.load(source);
308
+ if (parsed !== undefined && parsed !== null && (typeof parsed !== 'object' || Array.isArray(parsed))) {
309
+ throw new Error('expected a YAML mapping at the document root');
310
+ }
311
+ document = yamlRecord(parsed);
312
+ }
313
+ catch (error) {
314
+ report.ambiguities.push({ code: 'INVALID_SOURCE', path: sourceRel, detail: errorMessage(error) });
315
+ valid = false;
316
+ continue;
317
+ }
318
+ if (sourcePath !== targetAbs && /(^|\n)\s*#/.test(source)) {
319
+ report.losses.push({
320
+ code: 'YAML_COMMENTS',
321
+ path: sourceRel,
322
+ detail: 'Comments cannot be represented in the consolidated YAML document; authored values are preserved.',
323
+ });
324
+ }
325
+ for (const [section, rawValue] of Object.entries(document)) {
326
+ if (MODELING_LIST_SECTIONS.has(section) && !Array.isArray(rawValue)) {
327
+ report.ambiguities.push({
328
+ code: 'INVALID_SOURCE',
329
+ path: `${sourceRel}.${section}`,
330
+ detail: `Modeling section "${section}" must be a list; the migration will not guess how object keys map to stable ids.`,
331
+ });
332
+ valid = false;
333
+ continue;
334
+ }
335
+ if (Array.isArray(rawValue)) {
336
+ const destination = Array.isArray(merged[section]) ? merged[section] : [];
337
+ for (const rawItem of rawValue) {
338
+ const item = yamlRecord(rawItem);
339
+ const localId = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : undefined;
340
+ if (!localId) {
341
+ destination.push(rawItem);
342
+ report.losses.push({
343
+ code: 'UNKEYED_MODELING_OBJECT',
344
+ path: `${sourceRel}.${section}`,
345
+ detail: 'Object is preserved but cannot receive a deterministic qualified identity until an id is added.',
346
+ });
347
+ continue;
348
+ }
349
+ const key = `${section}:${localId}`;
350
+ const prior = identities.get(key);
351
+ if (prior !== undefined) {
352
+ if (stableJson(prior) !== stableJson(rawItem)) {
353
+ report.ambiguities.push({
354
+ code: 'IDENTITY_COLLISION',
355
+ path: `${sourceRel}.${section}.${localId}`,
356
+ detail: `Conflicting definitions of ${section}.${localId} cannot be consolidated automatically.`,
357
+ });
358
+ valid = false;
359
+ }
360
+ continue;
361
+ }
362
+ identities.set(key, rawItem);
363
+ if (section === 'entities')
364
+ normalizeEntityDbtBinding(item, sourceRel, dbtIndex, report);
365
+ destination.push(item);
366
+ report.qualifiedIdentityRewrites.push({
367
+ domain,
368
+ kind: section.replace(/s$/, ''),
369
+ localId,
370
+ qualifiedId: `${domain}::${section.replace(/s$/, '')}::${localId}`,
371
+ });
372
+ }
373
+ merged[section] = destination;
374
+ }
375
+ else if (!(section in merged)) {
376
+ merged[section] = rawValue;
377
+ }
378
+ else if (stableJson(merged[section]) !== stableJson(rawValue)) {
379
+ report.ambiguities.push({
380
+ code: 'IDENTITY_COLLISION',
381
+ path: `${sourceRel}.${section}`,
382
+ detail: `Conflicting top-level modeling value "${section}" cannot be consolidated automatically.`,
383
+ });
384
+ valid = false;
385
+ }
386
+ }
387
+ }
388
+ if (!valid)
389
+ return;
390
+ const content = yaml.dump(merged, { noRefs: true, lineWidth: -1, sortKeys: false, noCompatMode: true }).trimEnd() + '\n';
391
+ const before = existsSync(targetAbs) ? readFileSync(targetAbs, 'utf8') : '';
392
+ const splitSources = sources.filter((source) => source !== targetAbs);
393
+ if (before !== content)
394
+ writes.push({ kind: 'file', target: targetRel, content });
395
+ for (const source of splitSources)
396
+ deletes.push(slash(relative(projectRoot, source)));
397
+ if (splitSources.length > 0) {
398
+ report.modelingConsolidations.push({
399
+ domain,
400
+ sources: sources.map((source) => slash(relative(projectRoot, source))),
401
+ target: targetRel,
402
+ });
403
+ }
404
+ }
405
+ const MODELING_LIST_SECTIONS = new Set(['entities', 'relationships', 'contracts', 'conformance', 'rules', 'exports', 'imports']);
406
+ function normalizeEntityDbtBinding(entity, sourcePath, dbtIndex, report) {
407
+ const id = typeof entity.id === 'string' ? entity.id : 'unknown';
408
+ const ref = typeof entity.dbt_model === 'string' ? entity.dbt_model.trim() : '';
409
+ if (!ref || !dbtIndex)
410
+ return;
411
+ if (dbtIndex.uniqueIds.has(ref))
412
+ return;
413
+ const matches = dbtIndex.aliases.get(normalizeDbtReference(ref)) ?? [];
414
+ if (matches.length === 1) {
415
+ entity.dbt_model = matches[0];
416
+ }
417
+ else if (matches.length > 1) {
418
+ report.ambiguities.push({
419
+ code: 'AMBIGUOUS_DBT_BINDING',
420
+ path: `${sourcePath}.entities.${id}.dbt_model`,
421
+ detail: `Binding "${ref}" matches multiple dbt nodes; use an exact dbt unique_id.`,
422
+ candidates: matches,
423
+ });
424
+ }
425
+ else {
426
+ report.losses.push({
427
+ code: 'MISSING_DBT_BINDING',
428
+ path: `${sourcePath}.entities.${id}.dbt_model`,
429
+ detail: `Binding "${ref}" is preserved but does not resolve in the current dbt manifest.`,
430
+ });
431
+ }
432
+ }
433
+ function planLegacyProducts(projectRoot, registry, report, writes, deletes) {
434
+ const claimedTargets = new Map();
435
+ for (const appsDir of findNamedDirectories(join(projectRoot, 'domains'), 'apps')) {
436
+ const pkg = registry.packageForPath(appsDir);
437
+ if (!pkg) {
438
+ report.ambiguities.push({ code: 'DOMAIN_UNRESOLVED', path: slash(relative(projectRoot, appsDir)), detail: 'No Domain Package owns this legacy Apps directory.' });
439
+ continue;
440
+ }
441
+ for (const entry of readdirSync(appsDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
442
+ if (!entry.isDirectory())
443
+ continue;
444
+ const sourceAbs = join(appsDir, entry.name);
445
+ const manifestPath = join(sourceAbs, 'dql.app.json');
446
+ if (!existsSync(manifestPath))
447
+ continue;
448
+ const sourceRel = slash(relative(projectRoot, sourceAbs));
449
+ let document;
450
+ try {
451
+ document = jsonRecord(JSON.parse(readFileSync(manifestPath, 'utf8')));
452
+ }
453
+ catch (error) {
454
+ report.ambiguities.push({ code: 'INVALID_SOURCE', path: `${sourceRel}/dql.app.json`, detail: errorMessage(error) });
455
+ continue;
456
+ }
457
+ const id = typeof document.id === 'string' ? document.id.trim() : '';
458
+ if (!id || id !== entry.name) {
459
+ report.ambiguities.push({
460
+ code: 'IDENTITY_COLLISION',
461
+ path: `${sourceRel}/dql.app.json`,
462
+ detail: `App folder "${entry.name}" and document id "${id || '(missing)'}" differ; choose the canonical global id explicitly.`,
463
+ });
464
+ continue;
465
+ }
466
+ if (!productOwnerMatches(document, pkg.id)) {
467
+ report.ambiguities.push({ code: 'DOMAIN_UNRESOLVED', path: `${sourceRel}/dql.app.json`, detail: `Existing domain ownership conflicts with path owner "${pkg.id}".` });
468
+ continue;
469
+ }
470
+ const targetRel = `apps/${id}`;
471
+ const claimedBy = claimedTargets.get(targetRel);
472
+ if (existsSync(join(projectRoot, targetRel)) || claimedBy) {
473
+ report.ambiguities.push({
474
+ code: 'TARGET_COLLISION',
475
+ path: targetRel,
476
+ detail: claimedBy
477
+ ? 'Multiple domain-local Apps resolve to the same global target; DQL will not choose one.'
478
+ : 'Global App target already exists; DQL will not merge or overwrite it.',
479
+ candidates: claimedBy ? [claimedBy, sourceRel] : [sourceRel, targetRel],
480
+ });
481
+ continue;
482
+ }
483
+ claimedTargets.set(targetRel, sourceRel);
484
+ document.ownerDomain = pkg.id;
485
+ if (!Array.isArray(document.usesDomains) || document.usesDomains.length === 0)
486
+ document.usesDomains = [pkg.id];
487
+ const manifestContent = `${JSON.stringify(document, null, 2)}\n`;
488
+ writes.push({ kind: 'directory', source: sourceRel, target: targetRel, patches: [{ path: 'dql.app.json', content: manifestContent }] });
489
+ deletes.push(sourceRel);
490
+ report.productMoves.push({ kind: 'app', domain: pkg.id, source: sourceRel, target: targetRel });
491
+ }
492
+ }
493
+ for (const notebooksDir of findNamedDirectories(join(projectRoot, 'domains'), 'notebooks')) {
494
+ const pkg = registry.packageForPath(notebooksDir);
495
+ if (!pkg) {
496
+ report.ambiguities.push({ code: 'DOMAIN_UNRESOLVED', path: slash(relative(projectRoot, notebooksDir)), detail: 'No Domain Package owns this legacy Notebooks directory.' });
497
+ continue;
498
+ }
499
+ for (const sourceAbs of walkFiles(notebooksDir).filter((path) => path.endsWith('.dqlnb'))) {
500
+ const sourceRel = slash(relative(projectRoot, sourceAbs));
501
+ const nested = slash(relative(notebooksDir, sourceAbs));
502
+ const targetRel = `notebooks/${nested}`;
503
+ const claimedBy = claimedTargets.get(targetRel);
504
+ if (existsSync(join(projectRoot, targetRel)) || claimedBy) {
505
+ report.ambiguities.push({
506
+ code: 'TARGET_COLLISION',
507
+ path: targetRel,
508
+ detail: claimedBy
509
+ ? 'Multiple domain-local Notebooks resolve to the same global target; DQL will not choose one.'
510
+ : 'Global Notebook target already exists; DQL will not merge or overwrite it.',
511
+ candidates: claimedBy ? [claimedBy, sourceRel] : [sourceRel, targetRel],
512
+ });
513
+ continue;
514
+ }
515
+ claimedTargets.set(targetRel, sourceRel);
516
+ let notebook;
517
+ try {
518
+ notebook = jsonRecord(JSON.parse(readFileSync(sourceAbs, 'utf8')));
519
+ }
520
+ catch (error) {
521
+ report.ambiguities.push({ code: 'INVALID_SOURCE', path: sourceRel, detail: errorMessage(error) });
522
+ continue;
523
+ }
524
+ const metadata = jsonRecord(notebook.metadata);
525
+ if (!productOwnerMatches(metadata, pkg.id)) {
526
+ report.ambiguities.push({ code: 'DOMAIN_UNRESOLVED', path: sourceRel, detail: `Existing notebook ownership conflicts with path owner "${pkg.id}".` });
527
+ continue;
528
+ }
529
+ metadata.ownerDomain = pkg.id;
530
+ if (!Array.isArray(metadata.usesDomains) || metadata.usesDomains.length === 0)
531
+ metadata.usesDomains = [pkg.id];
532
+ notebook.metadata = metadata;
533
+ const content = canonicalizeNotebook(JSON.stringify(notebook));
534
+ writes.push({ kind: 'file', target: targetRel, content });
535
+ deletes.push(sourceRel);
536
+ report.productMoves.push({ kind: 'notebook', domain: pkg.id, source: sourceRel, target: targetRel });
537
+ }
538
+ }
539
+ }
540
+ function productOwnerMatches(document, pathDomain) {
541
+ const owner = typeof document.ownerDomain === 'string' && document.ownerDomain.trim()
542
+ ? document.ownerDomain.trim()
543
+ : typeof document.domain === 'string' && document.domain.trim()
544
+ ? document.domain.trim()
545
+ : undefined;
546
+ return !owner || owner === pathDomain;
547
+ }
548
+ /** Apply only the exact reviewed fingerprint, with rollback across all source replacements. */
549
+ export function applyModelingMigration(projectRootInput, plan) {
550
+ const projectRoot = resolve(projectRootInput);
551
+ if (plan.report.ambiguities.length > 0)
552
+ throw new Error('Migration plan is blocked by ambiguities; no files were written.');
553
+ const current = planModelingMigration(projectRoot);
554
+ if (current.report.fingerprint !== plan.report.fingerprint || migrationShape(current) !== migrationShape(plan)) {
555
+ throw new Error('SOURCE_CHANGED: migration inputs changed after preview; run --dry-run again and review the new fingerprint.');
556
+ }
557
+ if (plan.writes.length === 0 && plan.deletes.length === 0)
558
+ return { written: [], removed: [] };
559
+ const stageRoot = join(projectRoot, '.dql', 'migration-staging', randomUUID());
560
+ const afterRoot = join(stageRoot, 'after');
561
+ const beforeRoot = join(stageRoot, 'before');
562
+ const deletedRoot = join(stageRoot, 'deleted');
563
+ const installed = [];
564
+ const backedUp = [];
565
+ const removed = [];
566
+ try {
567
+ for (const operation of plan.writes) {
568
+ const staged = join(afterRoot, operation.target);
569
+ mkdirSync(dirname(staged), { recursive: true });
570
+ if (operation.kind === 'file') {
571
+ writeFileSync(staged, operation.content, 'utf8');
572
+ }
573
+ else {
574
+ const source = safeMigrationPath(projectRoot, operation.source);
575
+ rejectSymlinks(source);
576
+ cpSync(source, staged, { recursive: true, errorOnExist: true, force: false });
577
+ for (const patch of operation.patches) {
578
+ const patchPath = safeMigrationPath(staged, patch.path);
579
+ mkdirSync(dirname(patchPath), { recursive: true });
580
+ writeFileSync(patchPath, patch.content, 'utf8');
581
+ }
582
+ }
583
+ }
584
+ for (const operation of plan.writes) {
585
+ const target = safeMigrationPath(projectRoot, operation.target);
586
+ const staged = join(afterRoot, operation.target);
587
+ if (existsSync(target)) {
588
+ const backup = join(beforeRoot, operation.target);
589
+ mkdirSync(dirname(backup), { recursive: true });
590
+ renameSync(target, backup);
591
+ backedUp.push(operation.target);
592
+ }
593
+ mkdirSync(dirname(target), { recursive: true });
594
+ renameSync(staged, target);
595
+ installed.push(operation.target);
596
+ }
597
+ for (const sourceRel of plan.deletes) {
598
+ if (plan.writes.some((operation) => operation.target === sourceRel))
599
+ continue;
600
+ const source = safeMigrationPath(projectRoot, sourceRel);
601
+ if (!existsSync(source))
602
+ continue;
603
+ const backup = join(deletedRoot, sourceRel);
604
+ mkdirSync(dirname(backup), { recursive: true });
605
+ renameSync(source, backup);
606
+ removed.push(sourceRel);
607
+ }
608
+ rmSync(stageRoot, { recursive: true, force: true });
609
+ return { written: installed, removed };
610
+ }
611
+ catch (error) {
612
+ for (const sourceRel of [...removed].reverse()) {
613
+ const source = safeMigrationPath(projectRoot, sourceRel);
614
+ const backup = join(deletedRoot, sourceRel);
615
+ if (existsSync(source))
616
+ rmSync(source, { recursive: true, force: true });
617
+ if (existsSync(backup)) {
618
+ mkdirSync(dirname(source), { recursive: true });
619
+ renameSync(backup, source);
620
+ }
621
+ }
622
+ for (const targetRel of [...installed].reverse()) {
623
+ const target = safeMigrationPath(projectRoot, targetRel);
624
+ if (existsSync(target))
625
+ rmSync(target, { recursive: true, force: true });
626
+ }
627
+ for (const targetRel of [...backedUp].reverse()) {
628
+ const target = safeMigrationPath(projectRoot, targetRel);
629
+ const backup = join(beforeRoot, targetRel);
630
+ if (existsSync(backup)) {
631
+ mkdirSync(dirname(target), { recursive: true });
632
+ renameSync(backup, target);
633
+ }
634
+ }
635
+ rmSync(stageRoot, { recursive: true, force: true });
636
+ throw error;
637
+ }
638
+ }
639
+ function readDbtBindingIndex(path) {
640
+ const manifest = jsonRecord(JSON.parse(readFileSync(path, 'utf8')));
641
+ const nodes = { ...jsonRecord(manifest.nodes), ...jsonRecord(manifest.sources) };
642
+ const uniqueIds = new Set();
643
+ const aliases = new Map();
644
+ for (const [uniqueId, raw] of Object.entries(nodes).sort(([a], [b]) => a.localeCompare(b))) {
645
+ const node = jsonRecord(raw);
646
+ if (node.resource_type !== 'model' && node.resource_type !== 'source')
647
+ continue;
648
+ uniqueIds.add(uniqueId);
649
+ const names = [node.name, node.alias, node.identifier, relationFromDbtNode(node)]
650
+ .filter((value) => typeof value === 'string' && value.trim().length > 0);
651
+ for (const name of names) {
652
+ const key = normalizeDbtReference(name);
653
+ const values = aliases.get(key) ?? [];
654
+ if (!values.includes(uniqueId))
655
+ values.push(uniqueId);
656
+ aliases.set(key, values.sort());
657
+ }
658
+ }
659
+ return { uniqueIds, aliases };
660
+ }
661
+ function relationFromDbtNode(node) {
662
+ return [node.database, node.schema, node.alias ?? node.identifier ?? node.name]
663
+ .filter((value) => typeof value === 'string' && value.trim().length > 0)
664
+ .join('.');
665
+ }
666
+ function normalizeDbtReference(value) {
667
+ return value.trim().replace(/^ref\(['"]|['"]\)$/g, '').replace(/["`]/g, '').toLowerCase();
668
+ }
669
+ function fingerprintMigrationState(projectRoot, dbtManifestPath) {
670
+ const hash = createHash('sha256');
671
+ const paths = [
672
+ join(projectRoot, 'dql.config.json'),
673
+ ...walkFiles(join(projectRoot, 'domains')),
674
+ ...walkFiles(join(projectRoot, 'apps')),
675
+ ...walkFiles(join(projectRoot, 'notebooks')),
676
+ ];
677
+ if (dbtManifestPath)
678
+ paths.push(dbtManifestPath);
679
+ const unique = [...new Set(paths.map((path) => resolve(path)))].sort();
680
+ for (const path of unique) {
681
+ if (path.includes(`${sep}.dql${sep}`))
682
+ continue;
683
+ hash.update(displayPath(projectRoot, path));
684
+ hash.update('\0');
685
+ if (existsSync(path) && statSync(path).isFile())
686
+ hash.update(readFileSync(path));
687
+ else
688
+ hash.update('<missing>');
689
+ hash.update('\0');
690
+ }
691
+ return `sha256:${hash.digest('hex')}`;
692
+ }
693
+ function migrationShape(plan) {
694
+ return stableJson({
695
+ writes: plan.writes.map((operation) => operation.kind === 'file'
696
+ ? { kind: operation.kind, target: operation.target, content: operation.content }
697
+ : { kind: operation.kind, source: operation.source, target: operation.target, patches: operation.patches }),
698
+ deletes: plan.deletes,
699
+ ambiguities: plan.report.ambiguities,
700
+ losses: plan.report.losses,
701
+ });
702
+ }
703
+ function findNamedDirectories(root, name) {
704
+ if (!existsSync(root))
705
+ return [];
706
+ const found = [];
707
+ const stack = [root];
708
+ while (stack.length > 0) {
709
+ const dir = stack.pop();
710
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
711
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules')
712
+ continue;
713
+ const path = join(dir, entry.name);
714
+ if (entry.name === name)
715
+ found.push(path);
716
+ else
717
+ stack.push(path);
718
+ }
719
+ }
720
+ return found.sort();
721
+ }
722
+ function walkFiles(root) {
723
+ if (!existsSync(root))
724
+ return [];
725
+ const files = [];
726
+ const stack = [root];
727
+ while (stack.length > 0) {
728
+ const dir = stack.pop();
729
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
730
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
731
+ continue;
732
+ const path = join(dir, entry.name);
733
+ if (entry.isDirectory())
734
+ stack.push(path);
735
+ else if (entry.isFile())
736
+ files.push(path);
737
+ else if (entry.isSymbolicLink())
738
+ files.push(path);
739
+ }
740
+ }
741
+ return files.sort();
742
+ }
743
+ function rejectSymlinks(root) {
744
+ for (const path of [root, ...walkFiles(root)]) {
745
+ if (lstatSync(path).isSymbolicLink())
746
+ throw new Error(`Migration refuses symlinked source: ${path}`);
747
+ }
748
+ }
749
+ function safeMigrationPath(root, relativePath) {
750
+ const base = resolve(root);
751
+ const path = resolve(base, relativePath);
752
+ if (path !== base && !path.startsWith(`${base}${sep}`))
753
+ throw new Error(`Migration path escapes project root: ${relativePath}`);
754
+ return path;
755
+ }
756
+ function yamlRecord(value) {
757
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
758
+ }
759
+ function jsonRecord(value) {
760
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
761
+ }
762
+ function stableJson(value) {
763
+ return JSON.stringify(value, (_key, raw) => {
764
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
765
+ return raw;
766
+ return Object.fromEntries(Object.entries(raw).sort(([a], [b]) => a.localeCompare(b)));
767
+ });
768
+ }
769
+ function displayPath(projectRoot, path) {
770
+ const rel = relative(projectRoot, path);
771
+ return rel.startsWith('..') ? resolve(path) : slash(rel);
772
+ }
773
+ function slash(value) {
774
+ return value.replace(/\\/g, '/');
775
+ }
776
+ function compareIssue(a, b) {
777
+ return `${a.path}:${a.code}:${a.detail}`.localeCompare(`${b.path}:${b.code}:${b.detail}`);
778
+ }
779
+ function errorMessage(error) {
780
+ return error instanceof Error ? error.message : String(error);
781
+ }
782
+ async function runDataLexManifestMigration(flags) {
783
+ const projectRoot = findProjectRoot(process.cwd());
784
+ const datalexManifestPath = flags.datalexManifestPath || flags.input;
785
+ if (!datalexManifestPath) {
786
+ console.error('Usage: dql migrate datalex --input <datalex-manifest.json> [--dry-run|--apply]');
787
+ process.exitCode = 1;
788
+ return;
789
+ }
790
+ const absoluteDataLex = resolve(datalexManifestPath);
791
+ if (!existsSync(absoluteDataLex)) {
792
+ console.error(`DataLex manifest not found: ${absoluteDataLex}`);
793
+ process.exitCode = 1;
794
+ return;
795
+ }
796
+ const dbtManifestPath = resolveDbtManifestPath(projectRoot);
797
+ if (!dbtManifestPath) {
798
+ console.error('No dbt manifest found. Run dbt parse/compile or configure dql.config.json before migrating DataLex.');
799
+ process.exitCode = 1;
800
+ return;
801
+ }
802
+ const plan = planDataLexMigration({ projectRoot, datalexManifestPath: absoluteDataLex, dbtManifestPath });
803
+ const applyRequested = flags.apply === true && flags.dryRun !== true;
804
+ const blocked = plan.report.ambiguities.length > 0;
805
+ const apply = applyRequested && !blocked;
806
+ const result = apply ? applyDataLexMigration(projectRoot, plan) : undefined;
807
+ if (applyRequested && blocked)
808
+ process.exitCode = 1;
809
+ if (flags.format === 'json') {
810
+ console.log(JSON.stringify({ mode: blocked && applyRequested ? 'blocked' : apply ? 'applied' : 'dry-run', plan, result }, null, 2));
811
+ return;
812
+ }
813
+ console.log(`\n DataLex → DQL dbt-first migration (${blocked && applyRequested ? 'blocked' : apply ? 'applied' : 'dry-run'})`);
814
+ console.log(` matched dbt entities: ${plan.report.matchedEntities.length}`);
815
+ console.log(` drafted DQL objects: ${plan.report.draftedObjects.length}`);
816
+ console.log(` dropped dbt mirrors: ${plan.report.droppedDbtMirrors.length}`);
817
+ console.log(` explicit losses: ${plan.report.losses.length}`);
818
+ console.log(` ambiguities: ${plan.report.ambiguities.length}`);
819
+ console.log(` auto-certified: ${plan.report.autoCertified}`);
820
+ if (apply) {
821
+ console.log(` wrote: ${result?.written.length ?? 0}; unchanged: ${result?.unchanged.length ?? 0}`);
822
+ }
823
+ else {
824
+ console.log(blocked
825
+ ? ' no files written; replace ambiguous names with exact dbt unique IDs and preview again.'
826
+ : ' no files written; add --apply after reviewing the generated plan.');
827
+ }
828
+ for (const ambiguity of plan.report.ambiguities) {
829
+ console.log(` ✗ ${ambiguity.path}: ${ambiguity.reason} (${ambiguity.candidates.join(', ')})`);
830
+ }
831
+ for (const file of plan.files)
832
+ console.log(` ${file.kind}: ${file.path}`);
833
+ }
834
+ /**
835
+ * A read-only migration audit. Existing blocks keep their legacy execution
836
+ * defaults; the report identifies only the contracts that need a human review
837
+ * before AI may adapt their values.
838
+ */
839
+ export async function runParameterMigrateCheck(flags) {
840
+ const root = findProjectRoot(resolve(flags.input || process.cwd()));
841
+ const report = { scanned: 0, blocksWithParameters: 0, issues: [] };
842
+ const contracts = new Map();
843
+ for (const absPath of walkDqlFiles(root)) {
844
+ if (!absPath.endsWith('.dql'))
845
+ continue;
846
+ report.scanned += 1;
847
+ const source = readFileSync(absPath, 'utf-8');
848
+ const program = new Parser(source, absPath).parse();
849
+ for (const statement of program.statements) {
850
+ if (statement.kind !== NodeKind.BlockDecl)
851
+ continue;
852
+ const block = statement;
853
+ const path = relative(root, absPath) || absPath;
854
+ const names = new Set(block.params?.params.map((parameter) => parameter.name) ?? []);
855
+ const definitions = blockParameterDefinitions(block);
856
+ if (definitions.length)
857
+ report.blocksWithParameters += 1;
858
+ for (const interpolation of block.query?.interpolations ?? []) {
859
+ if (!names.has(interpolation.variableName)) {
860
+ report.issues.push({
861
+ path,
862
+ block: block.name,
863
+ kind: 'undeclared_placeholder',
864
+ detail: `\${${interpolation.variableName}} is not declared in params.`,
865
+ });
866
+ }
867
+ }
868
+ for (const policy of block.parameterPolicy ?? []) {
869
+ if (!names.has(policy.name)) {
870
+ report.issues.push({
871
+ path,
872
+ block: block.name,
873
+ kind: 'policy_without_definition',
874
+ detail: `parameterPolicy.${policy.name} has no parameter declaration.`,
875
+ });
876
+ }
877
+ }
878
+ for (const error of resolveBlockParameterValues(definitions).errors) {
879
+ report.issues.push({ path, block: block.name, kind: 'incompatible_default', detail: error });
880
+ }
881
+ if (block.blockType === 'semantic') {
882
+ for (const binding of block.filterBindings ?? []) {
883
+ if (!names.has(binding.filter)) {
884
+ report.issues.push({
885
+ path,
886
+ block: block.name,
887
+ kind: 'ambiguous_semantic_filter',
888
+ detail: `filterBindings.${binding.filter} does not map to a typed parameter.`,
889
+ });
890
+ }
891
+ }
892
+ }
893
+ const sql = block.query?.rawSQL
894
+ ?.replace(/'(?:[^']|'')*'/g, '?')
895
+ .replace(/\b\d+(?:\.\d+)?\b/g, '?')
896
+ .replace(/\s+/g, ' ')
897
+ .trim()
898
+ .toLowerCase();
899
+ if (sql && definitions.length) {
900
+ const signature = `${sql}::${definitions.map((parameter) => `${parameter.name}:${parameter.type}:${parameter.binding?.kind ?? 'unbound'}`).sort().join('|')}`;
901
+ const entries = contracts.get(signature) ?? [];
902
+ entries.push({ path, block: block.name });
903
+ contracts.set(signature, entries);
904
+ }
905
+ }
906
+ }
907
+ for (const entries of contracts.values()) {
908
+ if (entries.length < 2)
909
+ continue;
910
+ const detail = `Equivalent parameterized contract also appears in ${entries.map((entry) => `${entry.block} (${entry.path})`).join(', ')}.`;
911
+ for (const entry of entries) {
912
+ report.issues.push({ path: entry.path, block: entry.block, kind: 'duplicate_parameterized_contract', detail });
913
+ }
914
+ }
915
+ if (flags.format === 'json') {
916
+ console.log(JSON.stringify(report, null, 2));
917
+ }
918
+ else {
919
+ console.log('\n DQL parameter migration audit');
920
+ console.log(' ─────────────────────────────');
921
+ console.log(` Project: ${root}`);
922
+ console.log(` Scanned: ${report.scanned}`);
923
+ console.log(` Blocks with parameters: ${report.blocksWithParameters}`);
924
+ console.log(` Review issues: ${report.issues.length}`);
925
+ for (const issue of report.issues.slice(0, 50)) {
926
+ console.log(` ✗ ${issue.path}${issue.block ? ` [${issue.block}]` : ''}: ${issue.detail}`);
927
+ }
928
+ if (report.issues.length > 50)
929
+ console.log(` ... ${report.issues.length - 50} more`);
930
+ console.log('');
931
+ }
932
+ if (flags.check && report.issues.length)
933
+ process.exitCode = 1;
934
+ }
126
935
  export async function runLayoutMigrate(flags) {
127
936
  if (flags.to !== 'domain-first') {
128
937
  throw new Error('Usage: dql migrate layout --to domain-first [--dry-run]');