@duckcodeailabs/dql-cli 1.11.5 → 1.11.7

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.
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, s
5
5
  import { createRequire } from "node:module";
6
6
  import { homedir } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
- import { dirname, extname, join, normalize, relative, resolve, } from "node:path";
8
+ import { dirname, extname, join, normalize, relative, resolve, sep, } from "node:path";
9
9
  import { Readable } from "node:stream";
10
10
  import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, planMixedSourceNotebookSql, planMixedSourceSql, } from "./mixed-source-sql.js";
11
11
  import { resolveNpmInvocation } from './npm-runtime.js';
@@ -19,7 +19,7 @@ import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from '
19
19
  import { resolveRetrievalHealthStatus } from './retrieval-health.js';
20
20
  import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
21
21
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
22
- import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, } from '@duckcodeailabs/dql-agent';
22
+ import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, propose, proposePlan, recordCorrectionTrace, listHintsFromGit, evaluateHint, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, } from '@duckcodeailabs/dql-agent';
23
23
  import { gatherProposeEnrichment } from './propose-enrich.js';
24
24
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
25
25
  import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
@@ -97,6 +97,27 @@ function agentRunString(value) {
97
97
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
98
98
  }
99
99
  /** UI catalog fallback labels are not declared project domains. */
100
+ /**
101
+ * Resolve a UI-pinned domain scope, tolerating one that no longer exists.
102
+ *
103
+ * The Ask scope persists across reloads, so a domain that was renamed, deleted,
104
+ * or simply mistyped would otherwise throw `Unknown domain: x` and BLOCK every
105
+ * question with "no answer produced" until the user found the chip and cleared
106
+ * it. A scope that cannot be resolved should widen the search, never wedge the
107
+ * surface. Anything other than an unresolvable scope still throws, because that
108
+ * is a real contract violation worth reporting.
109
+ */
110
+ export function resolveUiDomainContext(input) {
111
+ try {
112
+ return resolveDomainContextEnvelope(input);
113
+ }
114
+ catch (error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ if (/^Unknown domain:|^Unknown model area:|^Ambiguous model area:/.test(message))
117
+ return undefined;
118
+ throw error;
119
+ }
120
+ }
100
121
  export function normalizeAgentRunDomain(value) {
101
122
  const domain = agentRunString(value);
102
123
  if (!domain)
@@ -1092,7 +1113,7 @@ export async function startLocalServer(opts) {
1092
1113
  const buildDirectBuildContext = async (input) => {
1093
1114
  const snapshot = projectSnapshot();
1094
1115
  const activeDomain = normalizeAgentRunDomain(input.domain);
1095
- const domainContext = resolveDomainContextEnvelope({
1116
+ const domainContext = resolveUiDomainContext({
1096
1117
  manifest: snapshot.manifest,
1097
1118
  activeDomain,
1098
1119
  purpose: input.purpose,
@@ -1265,7 +1286,7 @@ export async function startLocalServer(opts) {
1265
1286
  const requestedModelAreaId = agentRunWorkspaceValue(request, 'modelAreaId');
1266
1287
  const runProjectSnapshot = projectSnapshot();
1267
1288
  const domainContext = requestedDomain
1268
- ? resolveDomainContextEnvelope({
1289
+ ? resolveUiDomainContext({
1269
1290
  manifest: runProjectSnapshot.manifest,
1270
1291
  activeDomain: requestedDomain,
1271
1292
  purpose: requestedPurpose,
@@ -2646,7 +2667,7 @@ export async function startLocalServer(opts) {
2646
2667
  strictness: request.analysisDepth === 'deep' ? 'exploratory' : 'balanced',
2647
2668
  limit: request.analysisDepth === 'deep' ? 120 : 80,
2648
2669
  domainContext: requestedDomain
2649
- ? resolveDomainContextEnvelope({
2670
+ ? resolveUiDomainContext({
2650
2671
  manifest: snapshot.manifest,
2651
2672
  activeDomain: requestedDomain,
2652
2673
  purpose: agentRunWorkspaceValue(request, 'purpose'),
@@ -6359,6 +6380,128 @@ export async function startLocalServer(opts) {
6359
6380
  // the approval, so the derived candidate is approved immediately unless the
6360
6381
  // caller opts out. Advisory only: never overrides certified routing. The
6361
6382
  // multi-tenant review workflow + automated distillation stay a cloud feature.
6383
+ // Hint review queue. Corrections are captured as CANDIDATES; a hint only
6384
+ // starts shaping other people's answers once a human approves it here.
6385
+ if (req.method === 'GET' && path === '/api/agent/hints') {
6386
+ try {
6387
+ const status = url.searchParams.get('status');
6388
+ const all = listHintsFromGit(projectRoot);
6389
+ const hints = status ? all.filter((hint) => hint.status === status) : all;
6390
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6391
+ res.end(serializeJSON({
6392
+ hints: hints.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).slice(0, 200),
6393
+ counts: {
6394
+ candidate: all.filter((hint) => hint.status === 'candidate').length,
6395
+ approved: all.filter((hint) => hint.status === 'approved').length,
6396
+ rejected: all.filter((hint) => hint.status === 'rejected').length,
6397
+ },
6398
+ }));
6399
+ }
6400
+ catch (error) {
6401
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6402
+ res.end(serializeJSON({ hints: [], counts: { candidate: 0, approved: 0, rejected: 0 }, error: error instanceof Error ? error.message : String(error) }));
6403
+ }
6404
+ return;
6405
+ }
6406
+ const hintReviewMatch = /^\/api\/agent\/hints\/([^/]+)\/review$/.exec(path);
6407
+ if (req.method === 'POST' && hintReviewMatch) {
6408
+ try {
6409
+ const hintId = decodeURIComponent(hintReviewMatch[1]);
6410
+ const body = await readJSON(req).catch(() => null);
6411
+ const decision = body?.decision === 'approved' || body?.decision === 'rejected' ? body.decision : null;
6412
+ if (!decision) {
6413
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
6414
+ res.end(serializeJSON({ error: "decision must be 'approved' or 'rejected'." }));
6415
+ return;
6416
+ }
6417
+ const reviewer = typeof body?.reviewer === 'string' && body.reviewer.trim()
6418
+ ? body.reviewer.trim()
6419
+ : (resolveLocalOwner(projectRoot) ?? 'local');
6420
+ const note = typeof body?.note === 'string' && body.note.trim() ? body.note.trim() : undefined;
6421
+ const snapshotId = projectSnapshot().snapshotId;
6422
+ // dbt-first v3 requires a PASSED evaluation artifact recorded against the
6423
+ // same snapshot before a candidate may be approved. Approving from the
6424
+ // review queue therefore runs the hint's required evaluation first: the
6425
+ // corrected SQL is re-validated against the current project so a hint can
6426
+ // never be promoted on evidence that has gone stale.
6427
+ if (decision === 'approved') {
6428
+ const pending = listHintsFromGit(projectRoot).find((entry) => entry.id === hintId);
6429
+ if (pending?.status === 'candidate' && pending.requiredEvaluation && !pending.evaluationId) {
6430
+ // The evaluation must be a real check, not a rubber stamp: the
6431
+ // corrected SQL has to parse and reference at least one relation
6432
+ // against the CURRENT project. A correction that no longer analyses
6433
+ // cannot be promoted into everyone's prompt.
6434
+ const validation = (() => {
6435
+ const sql = pending.correctedSql?.trim();
6436
+ if (!sql)
6437
+ return { ok: false, error: 'The correction carries no SQL to verify.' };
6438
+ try {
6439
+ const analysis = analyzeSqlReferences(sql);
6440
+ if (!analysis.tables.length)
6441
+ return { ok: false, error: 'Corrected SQL references no known relation.' };
6442
+ return { ok: true };
6443
+ }
6444
+ catch (error) {
6445
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
6446
+ }
6447
+ })();
6448
+ try {
6449
+ evaluateHint(projectRoot, {
6450
+ hintId,
6451
+ snapshotId,
6452
+ evaluator: reviewer,
6453
+ evaluation: pending.requiredEvaluation,
6454
+ checks: [{
6455
+ name: 'corrected-sql-validates',
6456
+ passed: validation.ok,
6457
+ evidence: validation.ok ? 'Corrected SQL passed local context validation.' : validation.error,
6458
+ }],
6459
+ evidence: [`snapshot: ${snapshotId}`, `reviewer: ${reviewer}`],
6460
+ });
6461
+ }
6462
+ catch (evaluationError) {
6463
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
6464
+ res.end(serializeJSON({ ok: false, error: evaluationError instanceof Error ? evaluationError.message : String(evaluationError) }));
6465
+ return;
6466
+ }
6467
+ }
6468
+ }
6469
+ const reviewed = reviewHint(projectRoot, { hintId, decision, reviewer, snapshotId, ...(note ? { note } : {}) });
6470
+ if (!reviewed) {
6471
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
6472
+ res.end(serializeJSON({ ok: false, error: 'Hint not found.' }));
6473
+ return;
6474
+ }
6475
+ const hint = reviewed.hint;
6476
+ // Keep the mirrored advisory memory in step with the decision, so an
6477
+ // approved lesson is recalled and a rejected one stops being recalled.
6478
+ try {
6479
+ const memory = new MemoryStore(defaultMemoryPath(projectRoot));
6480
+ memory.upsert({
6481
+ id: `mem_${hint.id}`,
6482
+ scope: 'project',
6483
+ title: hint.title,
6484
+ content: hint.guidance,
6485
+ tags: [hint.scope.metric, hint.scope.domain, hint.scope.dbtModel].filter((x) => Boolean(x)),
6486
+ source: 'correction',
6487
+ confidence: 0.9,
6488
+ importance: 0.85,
6489
+ enabled: decision === 'approved',
6490
+ });
6491
+ memory.close();
6492
+ }
6493
+ catch {
6494
+ /* best-effort */
6495
+ }
6496
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6497
+ res.end(serializeJSON({ ok: true, hint }));
6498
+ }
6499
+ catch (error) {
6500
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
6501
+ res.end(serializeJSON({ ok: false, error: error instanceof Error ? error.message : String(error) }));
6502
+ }
6503
+ return;
6504
+ }
6362
6505
  if (req.method === 'POST' && path === '/api/agent/learnings/correction') {
6363
6506
  const body = await readJSON(req).catch(() => null);
6364
6507
  const question = body && typeof body.question === 'string' ? body.question.trim() : '';
@@ -6382,6 +6525,29 @@ export async function startLocalServer(opts) {
6382
6525
  const rationale = typeof body.rationale === 'string' && body.rationale.trim() ? body.rationale.trim() : undefined;
6383
6526
  const author = typeof body.author === 'string' ? body.author : (resolveLocalOwner(projectRoot) ?? undefined);
6384
6527
  try {
6528
+ // dbt-first manifest v3 requires reviewable provenance before a correction
6529
+ // may be recorded at all. The client cannot know most of it, so derive it
6530
+ // here: without this the endpoint rejects every correction on exactly the
6531
+ // governed projects the learning loop is meant to serve.
6532
+ const correctionEvidence = Array.isArray(body.evidence)
6533
+ ? body.evidence.map(String).filter(Boolean)
6534
+ : [
6535
+ `question: ${question}`,
6536
+ ...(wrongSql ? [`previous SQL: ${wrongSql.replace(/\s+/g, ' ').slice(0, 400)}`] : []),
6537
+ `corrected SQL: ${correctedSql.replace(/\s+/g, ' ').slice(0, 400)}`,
6538
+ ...(rationale ? [`rationale: ${rationale}`] : []),
6539
+ ];
6540
+ const correctionSnapshotId = typeof body.snapshotId === 'string' && body.snapshotId.trim()
6541
+ ? body.snapshotId.trim()
6542
+ : projectSnapshot().snapshotId;
6543
+ const correctionFailedRoute = typeof body.failedRoute === 'string' && body.failedRoute.trim()
6544
+ ? body.failedRoute.trim()
6545
+ : 'generated_answer';
6546
+ // Must match the name emitCorrectionEvalCase writes, so the required
6547
+ // evaluation and the emitted regression case are the same artifact.
6548
+ const correctionEvaluation = typeof body.requiredEvaluation === 'string' && body.requiredEvaluation.trim()
6549
+ ? body.requiredEvaluation.trim()
6550
+ : `correction: ${question.slice(0, 60)}`;
6385
6551
  const { trace, hint } = recordCorrectionTrace(projectRoot, {
6386
6552
  question,
6387
6553
  scope,
@@ -6390,19 +6556,29 @@ export async function startLocalServer(opts) {
6390
6556
  correctedSql,
6391
6557
  rationale,
6392
6558
  author,
6559
+ failedRoute: correctionFailedRoute,
6560
+ evidence: correctionEvidence,
6561
+ snapshotId: correctionSnapshotId,
6562
+ requiredEvaluation: correctionEvaluation,
6393
6563
  hintTitle: typeof body.title === 'string' && body.title.trim() ? body.title.trim() : undefined,
6394
6564
  hintGuidance: typeof body.guidance === 'string' && body.guidance.trim() ? body.guidance.trim() : undefined,
6395
6565
  tags: Array.isArray(body.tags) ? body.tags.map(String) : undefined,
6396
6566
  });
6567
+ // Capture always, approve deliberately. A hint is injected into the
6568
+ // generation prompt as a "human-approved correction" and shapes every
6569
+ // future answer in scope — one analyst's local fix must not silently
6570
+ // become a team-wide rule across thousands of models. The trace and the
6571
+ // candidate are always recorded, so nothing is lost; approval is a
6572
+ // one-click review in Settings. Callers may still opt in explicitly.
6397
6573
  let approvedHint = hint;
6398
- if (body.approve !== false) {
6399
- reviewHint(projectRoot, { hintId: hint.id, decision: 'approved', reviewer: author ?? 'local', note: 'Self-approved (OSS single-user).' });
6574
+ if (body.approve === true) {
6575
+ reviewHint(projectRoot, { hintId: hint.id, decision: 'approved', reviewer: author ?? 'local', note: 'Approved on capture.' });
6400
6576
  approvedHint = { ...hint, status: 'approved' };
6401
6577
  }
6402
6578
  // W4.3 — turn the correction into a durable regression eval case so the wrong
6403
6579
  // answer can never silently return. Best-effort; never blocks the correction.
6404
6580
  try {
6405
- emitCorrectionEvalCase(projectRoot, { question, correctedSql });
6581
+ emitCorrectionEvalCase(projectRoot, { question, correctedSql, name: correctionEvaluation });
6406
6582
  }
6407
6583
  catch {
6408
6584
  /* best-effort */
@@ -6419,7 +6595,10 @@ export async function startLocalServer(opts) {
6419
6595
  source: 'correction',
6420
6596
  confidence: 0.9,
6421
6597
  importance: 0.85,
6422
- enabled: true,
6598
+ // A candidate is evidence, not a rule. Mirroring it as an ENABLED
6599
+ // memory would inject it into prompts through the side door and
6600
+ // defeat the approval gate; it is enabled when the hint is approved.
6601
+ enabled: approvedHint.status === 'approved',
6423
6602
  });
6424
6603
  memory.close();
6425
6604
  }
@@ -6941,6 +7120,55 @@ export async function startLocalServer(opts) {
6941
7120
  }
6942
7121
  return;
6943
7122
  }
7123
+ // DELETE /api/notebooks?path=notebooks/x.dqlnb — remove a notebook file.
7124
+ // Deliberately narrow: only .dqlnb/.dql files under notebooks/, resolved and
7125
+ // re-checked against the project root so a crafted path cannot escape it.
7126
+ if (req.method === 'DELETE' && path === '/api/notebooks') {
7127
+ try {
7128
+ const relativePath = url.searchParams.get('path')?.trim();
7129
+ if (!relativePath) {
7130
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
7131
+ res.end(serializeJSON({ ok: false, error: 'Missing notebook path.' }));
7132
+ return;
7133
+ }
7134
+ const normalized = relativePath.replace(/\\/g, '/').replace(/^\.\//, '');
7135
+ if (!normalized.startsWith('notebooks/') || !/\.(dqlnb|dql)$/i.test(normalized) || normalized.includes('..')) {
7136
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
7137
+ res.end(serializeJSON({ ok: false, error: 'Only notebook files under notebooks/ can be deleted.' }));
7138
+ return;
7139
+ }
7140
+ const absolute = resolve(projectRoot, normalized);
7141
+ const rootPrefix = `${resolve(projectRoot)}${sep}`;
7142
+ if (!absolute.startsWith(rootPrefix)) {
7143
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
7144
+ res.end(serializeJSON({ ok: false, error: 'Path escapes the project root.' }));
7145
+ return;
7146
+ }
7147
+ if (!existsSync(absolute)) {
7148
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
7149
+ res.end(serializeJSON({ ok: false, error: 'Notebook not found.' }));
7150
+ return;
7151
+ }
7152
+ rmSync(absolute, { force: true });
7153
+ // Drop the run-state sidecar too, or the next notebook created with the
7154
+ // same slug inherits the deleted one's cached results.
7155
+ const sidecar = absolute.replace(/\.(dqlnb|dql)$/i, '.run.json');
7156
+ if (sidecar !== absolute)
7157
+ rmSync(sidecar, { force: true });
7158
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
7159
+ res.end(serializeJSON({ ok: true, path: normalized }));
7160
+ }
7161
+ catch (error) {
7162
+ if (error instanceof DQLAccessDeniedError) {
7163
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
7164
+ res.end(serializeJSON({ ok: false, error: error.message, code: 'unauthorized' }));
7165
+ return;
7166
+ }
7167
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
7168
+ res.end(serializeJSON({ ok: false, error: error instanceof Error ? error.message : String(error) }));
7169
+ }
7170
+ return;
7171
+ }
6944
7172
  if (req.method === 'PUT' && path === '/api/notebook-content') {
6945
7173
  try {
6946
7174
  const body = await readJSON(req);
@@ -11361,7 +11589,22 @@ export async function startLocalServer(opts) {
11361
11589
  if (!source.trim())
11362
11590
  throw new Error('Provide an executable DQL artifact or block name.');
11363
11591
  const expectedReceipt = requestedArtifact?.executionReceipt;
11364
- if (expectedReceipt && expectedReceipt.sourceFingerprint !== executionFingerprint(source)) {
11592
+ // When a block name is given we deliberately re-read the CANONICAL block
11593
+ // from disk, so `source` is no longer the text the receipt was taken
11594
+ // from. Comparing the two made every "change an input and apply" fail
11595
+ // with "source changed after the answer was produced" whenever the saved
11596
+ // block differed from the answer's copy at all — even by formatting —
11597
+ // and changing inputs is precisely what that control is for.
11598
+ //
11599
+ // Check the receipt against the artifact the CLIENT holds (tamper
11600
+ // detection, its real purpose). A canonical block that has genuinely
11601
+ // moved on is reported alongside the fresh result instead of refusing
11602
+ // to run: the user still gets their answer and is told it was rebuilt.
11603
+ const receiptSource = requestedArtifact?.source ?? '';
11604
+ const sourceRefreshedFromDisk = Boolean(requestedBlockName)
11605
+ && receiptSource.trim().length > 0
11606
+ && receiptSource !== source;
11607
+ if (expectedReceipt && !sourceRefreshedFromDisk && expectedReceipt.sourceFingerprint !== executionFingerprint(source)) {
11365
11608
  throw new Error('DQL artifact source changed after the answer was produced. Refresh the answer before applying it.');
11366
11609
  }
11367
11610
  const parameters = body.parameters && typeof body.parameters === 'object' && !Array.isArray(body.parameters)
@@ -11378,7 +11621,10 @@ export async function startLocalServer(opts) {
11378
11621
  rowLimit: requestedArtifact?.limit,
11379
11622
  });
11380
11623
  const actualReceipt = result.executionReceipt;
11381
- if (expectedReceipt && actualReceipt) {
11624
+ // Same reasoning: if the canonical block was reloaded, its compiled SQL
11625
+ // is EXPECTED to differ, so this determinism check would fire on a
11626
+ // change we already know about.
11627
+ if (expectedReceipt && actualReceipt && !sourceRefreshedFromDisk) {
11382
11628
  if (expectedReceipt.parameterFingerprint === actualReceipt.parameterFingerprint
11383
11629
  && expectedReceipt.compiledSqlFingerprint !== actualReceipt.compiledSqlFingerprint) {
11384
11630
  throw new Error('DQL artifact execution changed while its source and inputs were unchanged. Refresh the answer before applying it.');
@@ -15604,11 +15850,29 @@ export function validateBlockStudioSource(source, semanticLayer) {
15604
15850
  message: 'Semantic block is not runnable yet. Select a metric and complete any required time settings.',
15605
15851
  }
15606
15852
  : {
15607
- severity: 'warning',
15853
+ // A block that DECLARES a query section and leaves it blank is broken,
15854
+ // not merely incomplete: it can never execute, yet it saved cleanly
15855
+ // because this was only a warning. A bare skeleton with no query
15856
+ // section at all stays a warning so draft authoring is unbroken.
15857
+ severity: /query\s*=\s*"""/i.test(source) ? 'error' : 'warning',
15608
15858
  code: 'sql_missing',
15609
- message: 'No executable SQL found in the block source.',
15859
+ message: /query\s*=\s*"""/i.test(source)
15860
+ ? 'The block declares a query section but it is empty. Add the SQL, or remove the query section and define metrics and dimensions instead.'
15861
+ : 'No executable SQL found in the block source.',
15610
15862
  });
15611
15863
  }
15864
+ // The mirror image: a semantic block is metrics x dimensions and must not carry
15865
+ // a query. Both together is the corrupt shape the analyzer lets through silently.
15866
+ if (semanticConfig.blockType === 'semantic') {
15867
+ const semanticQueryBody = source.match(/query\s*=\s*"""([\s\S]*?)"""/i)?.[1] ?? '';
15868
+ if (semanticQueryBody.trim()) {
15869
+ diagnostics.push({
15870
+ severity: 'error',
15871
+ code: 'semantic_block_has_query',
15872
+ message: 'Semantic blocks are defined by metrics and dimensions and must not contain a query section. Move the SQL into a custom block.',
15873
+ });
15874
+ }
15875
+ }
15612
15876
  const parameterInvocation = prepareBlockInvocation({ source, surface: 'block_studio' });
15613
15877
  if (parameterInvocation.errors.length > 0) {
15614
15878
  diagnostics.push(...parameterInvocation.errors.map((message) => ({ severity: 'error', code: 'parameter_parse', message })));