@kaelio/ktx 0.10.0 → 0.12.0

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 (193) hide show
  1. package/assets/python/{kaelio_ktx-0.10.0-py3-none-any.whl → kaelio_ktx-0.12.0-py3-none-any.whl} +0 -0
  2. package/assets/python/manifest.json +4 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/admin.js +1 -1
  5. package/dist/clack.d.ts +16 -0
  6. package/dist/clack.js +37 -6
  7. package/dist/claude-code-prompt-caching.js +1 -1
  8. package/dist/cli-program.js +7 -3
  9. package/dist/cli-runtime.d.ts +2 -0
  10. package/dist/cli-runtime.js +14 -8
  11. package/dist/commands/connection-commands.js +1 -1
  12. package/dist/commands/ingest-commands.js +4 -4
  13. package/dist/commands/mcp-commands.js +12 -12
  14. package/dist/commands/runtime-commands.js +4 -4
  15. package/dist/commands/setup-commands.js +6 -5
  16. package/dist/commands/sl-commands.js +1 -1
  17. package/dist/commands/sql-commands.js +1 -1
  18. package/dist/commands/status-commands.js +1 -1
  19. package/dist/community-cta.d.ts +11 -0
  20. package/dist/community-cta.js +19 -0
  21. package/dist/connection.js +1 -1
  22. package/dist/connectors/clickhouse/connector.js +1 -1
  23. package/dist/connectors/mysql/connector.js +1 -1
  24. package/dist/connectors/snowflake/connector.d.ts +1 -1
  25. package/dist/connectors/sqlite/connector.js +2 -25
  26. package/dist/connectors/sqlserver/connector.js +3 -3
  27. package/dist/context/connections/connection-type.d.ts +1 -1
  28. package/dist/context/connections/read-only-sql.d.ts +1 -0
  29. package/dist/context/connections/read-only-sql.js +116 -2
  30. package/dist/context/core/git-env.d.ts +12 -1
  31. package/dist/context/core/git-env.js +17 -2
  32. package/dist/context/core/git.service.d.ts +23 -0
  33. package/dist/context/core/git.service.js +86 -15
  34. package/dist/context/ingest/adapters/historic-sql/projection.js +2 -1
  35. package/dist/context/ingest/adapters/looker/client.js +7 -2
  36. package/dist/context/ingest/adapters/looker/factory.d.ts +8 -1
  37. package/dist/context/ingest/adapters/looker/factory.js +9 -0
  38. package/dist/context/ingest/adapters/looker/mapping.js +1 -1
  39. package/dist/context/ingest/adapters/looker/types.d.ts +1 -1
  40. package/dist/context/ingest/adapters/metabase/client.d.ts +1 -1
  41. package/dist/context/ingest/adapters/metabase/client.js +1 -1
  42. package/dist/context/ingest/adapters/metabase/local-metabase.adapter.js +1 -1
  43. package/dist/context/ingest/adapters/metabase/mapping.js +6 -6
  44. package/dist/context/ingest/artifact-gates.d.ts +2 -6
  45. package/dist/context/ingest/artifact-gates.js +5 -47
  46. package/dist/context/ingest/constrained-repair.d.ts +55 -0
  47. package/dist/context/ingest/constrained-repair.js +167 -0
  48. package/dist/context/ingest/final-gate-repair.d.ts +9 -11
  49. package/dist/context/ingest/final-gate-repair.js +40 -128
  50. package/dist/context/ingest/finalization-scope.d.ts +1 -1
  51. package/dist/context/ingest/finalization-scope.js +15 -15
  52. package/dist/context/ingest/ingest-bundle.runner.d.ts +1 -0
  53. package/dist/context/ingest/ingest-bundle.runner.js +101 -67
  54. package/dist/context/ingest/isolated-diff/patch-integrator.d.ts +6 -13
  55. package/dist/context/ingest/isolated-diff/patch-integrator.js +32 -109
  56. package/dist/context/ingest/isolated-diff/textual-conflict-resolver.d.ts +8 -9
  57. package/dist/context/ingest/isolated-diff/textual-conflict-resolver.js +63 -141
  58. package/dist/context/ingest/local-bundle-runtime.d.ts +2 -0
  59. package/dist/context/ingest/local-bundle-runtime.js +9 -10
  60. package/dist/context/ingest/local-ingest.d.ts +2 -0
  61. package/dist/context/ingest/local-ingest.js +2 -0
  62. package/dist/context/ingest/memory-flow/view-model.js +1 -1
  63. package/dist/context/ingest/stages/stage-3-work-units.d.ts +2 -6
  64. package/dist/context/ingest/stages/stage-3-work-units.js +2 -1
  65. package/dist/context/ingest/stages/validate-wu-sources.d.ts +7 -1
  66. package/dist/context/ingest/stages/validate-wu-sources.js +109 -4
  67. package/dist/context/ingest/tools/warehouse-verification/create-warehouse-verification-tools.d.ts +2 -0
  68. package/dist/context/ingest/tools/warehouse-verification/create-warehouse-verification-tools.js +1 -1
  69. package/dist/context/ingest/tools/warehouse-verification/discover-data.tool.js +3 -3
  70. package/dist/context/ingest/tools/warehouse-verification/sql-execution.tool.d.ts +3 -1
  71. package/dist/context/ingest/tools/warehouse-verification/sql-execution.tool.js +15 -1
  72. package/dist/context/llm/ai-sdk-runtime.js +2 -2
  73. package/dist/context/llm/claude-code-runtime.js +1 -1
  74. package/dist/context/llm/local-config.js +1 -1
  75. package/dist/context/llm/runtime-tools.js +2 -2
  76. package/dist/context/mcp/context-tools.js +7 -7
  77. package/dist/context/mcp/local-project-ports.js +23 -54
  78. package/dist/context/memory/local-memory.js +4 -1
  79. package/dist/context/memory/memory-agent.service.js +1 -1
  80. package/dist/context/project/config.d.ts +11 -4
  81. package/dist/context/project/config.js +85 -30
  82. package/dist/context/project/driver-schemas.js +1 -1
  83. package/dist/context/project/mappings-yaml-schema.js +2 -2
  84. package/dist/context/project/project.js +12 -4
  85. package/dist/context/scan/description-generation.js +4 -4
  86. package/dist/context/scan/local-enrichment-artifacts.js +2 -1
  87. package/dist/context/scan/local-scan.js +2 -2
  88. package/dist/context/scan/local-structural-artifacts.js +5 -5
  89. package/dist/context/scan/relationship-benchmark-report.js +1 -1
  90. package/dist/context/scan/relationship-discovery.js +3 -3
  91. package/dist/context/scan/relationship-llm-proposal.js +3 -3
  92. package/dist/context/sl/local-query.js +3 -33
  93. package/dist/context/sl/local-sl.d.ts +0 -8
  94. package/dist/context/sl/local-sl.js +44 -69
  95. package/dist/context/sl/semantic-layer.service.d.ts +25 -8
  96. package/dist/context/sl/semantic-layer.service.js +109 -56
  97. package/dist/context/sl/source-files.d.ts +46 -0
  98. package/dist/context/sl/source-files.js +131 -0
  99. package/dist/context/sl/tools/base-semantic-layer.tool.d.ts +2 -2
  100. package/dist/context/sl/tools/base-semantic-layer.tool.js +2 -7
  101. package/dist/context/sl/tools/sl-edit-source.tool.js +10 -8
  102. package/dist/context/sl/tools/sl-warehouse-validation.js +55 -27
  103. package/dist/context/sl/tools/sl-write-source.tool.js +12 -9
  104. package/dist/context/sql-analysis/dialect.d.ts +2 -0
  105. package/dist/context/sql-analysis/dialect.js +20 -0
  106. package/dist/context/tools/base-tool.d.ts +6 -19
  107. package/dist/context/tools/base-tool.js +0 -14
  108. package/dist/context-build-view.js +5 -5
  109. package/dist/database-tree-picker.js +18 -3
  110. package/dist/demo-assets.js +0 -1
  111. package/dist/doctor.d.ts +1 -1
  112. package/dist/doctor.js +31 -23
  113. package/dist/errors.d.ts +31 -0
  114. package/dist/errors.js +44 -0
  115. package/dist/ingest.d.ts +1 -1
  116. package/dist/ingest.js +8 -2
  117. package/dist/io/symbols.d.ts +2 -0
  118. package/dist/io/symbols.js +2 -0
  119. package/dist/io/tty.d.ts +17 -0
  120. package/dist/io/tty.js +21 -0
  121. package/dist/links.d.ts +1 -0
  122. package/dist/links.js +1 -0
  123. package/dist/llm/embedding-health.js +1 -1
  124. package/dist/llm/embedding-provider.js +3 -3
  125. package/dist/llm/model-provider.js +1 -1
  126. package/dist/local-adapters.d.ts +1 -0
  127. package/dist/local-adapters.js +2 -2
  128. package/dist/local-scan-connectors.js +1 -1
  129. package/dist/managed-local-embeddings.js +17 -8
  130. package/dist/managed-mcp-daemon.js +3 -3
  131. package/dist/managed-python-command.d.ts +7 -0
  132. package/dist/managed-python-command.js +34 -8
  133. package/dist/managed-python-daemon.js +2 -2
  134. package/dist/managed-python-http.js +3 -3
  135. package/dist/managed-python-runtime.d.ts +30 -1
  136. package/dist/managed-python-runtime.js +134 -18
  137. package/dist/managed-uv-release.d.ts +7 -0
  138. package/dist/managed-uv-release.js +11 -0
  139. package/dist/mcp-http-server.js +4 -4
  140. package/dist/mcp-server-factory.js +3 -3
  141. package/dist/mcp-stdio-server.js +1 -1
  142. package/dist/memory-flow-hud.js +2 -2
  143. package/dist/next-steps.js +2 -2
  144. package/dist/prompt-navigation.d.ts +17 -0
  145. package/dist/prompt-navigation.js +49 -3
  146. package/dist/prompts/memory_agent_bundle_ingest_work_unit.md +2 -2
  147. package/dist/prompts/memory_agent_external_ingest.md +2 -2
  148. package/dist/public-ingest-copy.js +1 -1
  149. package/dist/public-ingest.js +3 -3
  150. package/dist/release-version.js +1 -1
  151. package/dist/runtime-requirements.js +1 -1
  152. package/dist/runtime.js +9 -9
  153. package/dist/scan.js +1 -1
  154. package/dist/setup-agents.js +22 -35
  155. package/dist/setup-banner.d.ts +20 -0
  156. package/dist/setup-banner.js +39 -0
  157. package/dist/setup-context.js +24 -15
  158. package/dist/setup-databases.js +31 -59
  159. package/dist/setup-demo-tour.js +12 -8
  160. package/dist/setup-embeddings.js +9 -9
  161. package/dist/setup-interrupt.js +1 -1
  162. package/dist/setup-models.d.ts +4 -1
  163. package/dist/setup-models.js +54 -28
  164. package/dist/setup-project.js +29 -5
  165. package/dist/setup-prompts.js +16 -5
  166. package/dist/setup-ready-menu.js +1 -1
  167. package/dist/setup-sources.js +27 -7
  168. package/dist/setup.d.ts +25 -0
  169. package/dist/setup.js +90 -19
  170. package/dist/skills/analytics/SKILL.md +3 -3
  171. package/dist/skills/dbt_ingest/SKILL.md +3 -3
  172. package/dist/skills/looker_ingest/SKILL.md +3 -3
  173. package/dist/skills/lookml_ingest/SKILL.md +7 -7
  174. package/dist/skills/metabase_ingest/SKILL.md +4 -4
  175. package/dist/skills/metricflow_ingest/SKILL.md +15 -15
  176. package/dist/skills/notion_synthesize/SKILL.md +1 -1
  177. package/dist/skills/sl/SKILL.md +3 -3
  178. package/dist/skills/sl_capture/SKILL.md +1 -1
  179. package/dist/skills/wiki_capture/SKILL.md +1 -1
  180. package/dist/source-mapping.js +1 -1
  181. package/dist/startup-profile.js +1 -1
  182. package/dist/status-project.d.ts +0 -2
  183. package/dist/status-project.js +4 -6
  184. package/dist/telemetry/command-hook.d.ts +24 -0
  185. package/dist/telemetry/command-hook.js +37 -3
  186. package/dist/telemetry/events.d.ts +1 -1
  187. package/dist/telemetry/exception.js +14 -0
  188. package/dist/telemetry/index.d.ts +2 -2
  189. package/dist/telemetry/index.js +2 -2
  190. package/dist/text-ingest.js +1 -1
  191. package/dist/tree-picker-tui.d.ts +0 -1
  192. package/dist/tree-picker-tui.js +2 -3
  193. package/package.json +1 -1
@@ -2,6 +2,7 @@ import YAML from 'yaml';
2
2
  import { noopLogger } from '../../context/core/config.js';
3
3
  import { normalizeSemanticLayerDescriptions } from './description-normalization.js';
4
4
  import { isOverlaySource, resolvedSourceSchema, sourceDefinitionSchema, sourceOverlaySchema } from './schemas.js';
5
+ import { isSlYamlPath, resolveSlSourceFile, slDeclaredSourceName, slSourceFilePath } from './source-files.js';
5
6
  const SL_DIR_PREFIX = 'semantic-layer';
6
7
  const CONNECTION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
7
8
  /** @internal */
@@ -85,7 +86,7 @@ export class SemanticLayerService {
85
86
  async listConnectionIds() {
86
87
  try {
87
88
  const result = await this.configService.listFiles(SL_DIR_PREFIX);
88
- // Directories under semantic-layer/ are connectionIds. Local KTX projects use
89
+ // Directories under semantic-layer/ are connectionIds. Local ktx projects use
89
90
  // readable ids like "warehouse" and "dbt-main", not only UUIDs.
90
91
  return result.files
91
92
  .map((f) => f.replace(`${SL_DIR_PREFIX}/`, '').split('/')[0])
@@ -104,8 +105,31 @@ export class SemanticLayerService {
104
105
  return this.connections.listEnabledConnections(ids);
105
106
  }
106
107
  // ── YAML File Operations ────────────────────────────────
107
- sourcePath(connectionId, sourceName) {
108
- return `${SL_DIR_PREFIX}/${connectionId}/${sourceName}.yaml`;
108
+ // The in-file `name:` is the source's identity; the filename is only a derived
109
+ // label. Rewrites land on the file that already declares the name (humans may
110
+ // rename files freely); new sources get a derived filename. A file already
111
+ // sitting at the derived path that declares a name declares a *different* one
112
+ // (the resolver would have matched it otherwise) — fail instead of clobbering
113
+ // it. A nameless/unparseable file there is the broken remains of this very
114
+ // source (the derived path is a function of the name), so overwriting it is
115
+ // the repair path, not data loss.
116
+ async resolveWritePath(connectionId, sourceName) {
117
+ const existing = await resolveSlSourceFile(this.configService, connectionId, sourceName);
118
+ if (existing) {
119
+ return existing.path;
120
+ }
121
+ const path = slSourceFilePath(connectionId, sourceName);
122
+ let occupant = null;
123
+ try {
124
+ occupant = slDeclaredSourceName((await this.configService.readFile(path)).content);
125
+ }
126
+ catch {
127
+ return path;
128
+ }
129
+ if (occupant !== null) {
130
+ throw new Error(`Cannot write source '${sourceName}': ${path} already defines source '${occupant}'`);
131
+ }
132
+ return path;
109
133
  }
110
134
  async writeSource(connectionId, source, author, authorEmail, commitMessage, options) {
111
135
  // Writes are intentionally permissive — the agent must be able to save broken files so
@@ -140,38 +164,40 @@ export class SemanticLayerService {
140
164
  this.logger.warn(`[writeSource] '${source.name}': ${danglingRefs.join('; ')}. Saving anyway.`);
141
165
  }
142
166
  }
143
- const path = this.sourcePath(connectionId, source.name);
167
+ const path = await this.resolveWritePath(connectionId, source.name);
144
168
  const normalizedSource = normalizeSemanticLayerDescriptions(source);
145
169
  const content = YAML.stringify(normalizedSource, { indent: 2, lineWidth: 0, version: '1.1' });
146
170
  const message = commitMessage ?? `Update semantic layer source: ${source.name}`;
147
171
  const result = await this.configService.writeFile(path, content, author, authorEmail, message, {
148
172
  skipLock: options?.skipLock,
149
173
  });
150
- return { ...result, warnings };
174
+ // The filename is derived from (or resolved by) the source name — surface
175
+ // the actual path so callers don't have to re-resolve it.
176
+ return { ...result, path, warnings };
151
177
  }
178
+ /**
179
+ * Raw standalone/overlay file for a source, resolved by its in-file `name:`.
180
+ * Returns null when no file declares the name (the source may still exist as
181
+ * a manifest entry under `_schema/`).
182
+ */
152
183
  async readSourceFile(connectionId, sourceName) {
153
- const path = this.sourcePath(connectionId, sourceName);
154
- const result = await this.configService.readFile(path);
155
- return { content: result.content, path };
184
+ const file = await resolveSlSourceFile(this.configService, connectionId, sourceName);
185
+ return file ? { content: file.content, path: file.path } : null;
156
186
  }
157
187
  async loadSource(connectionId, sourceName) {
158
- let content;
159
- try {
160
- const result = await this.readSourceFile(connectionId, sourceName);
161
- content = result.content;
162
- }
163
- catch {
188
+ const file = await this.readSourceFile(connectionId, sourceName);
189
+ if (!file) {
164
190
  return null;
165
191
  }
166
192
  try {
167
- return YAML.parse(content);
193
+ return YAML.parse(file.content);
168
194
  }
169
195
  catch (error) {
170
196
  // Distinguish a YAML parse failure from a missing file. The file exists but
171
197
  // its contents are unparseable — callers that treat null as "does not exist"
172
198
  // could otherwise overwrite the broken file. Surface the parse failure via
173
199
  // the service logger so the broken source is at least visible.
174
- this.logger.warn(`[loadSource] ${connectionId}/${sourceName}.yaml: YAML parse failed: ${error instanceof Error ? error.message : String(error)}`);
200
+ this.logger.warn(`[loadSource] ${file.path}: YAML parse failed: ${error instanceof Error ? error.message : String(error)}`);
175
201
  return null;
176
202
  }
177
203
  }
@@ -182,7 +208,7 @@ export class SemanticLayerService {
182
208
  let allFiles;
183
209
  try {
184
210
  const result = await this.configService.listFiles(dir);
185
- allFiles = result.files.filter((f) => f.endsWith('.yaml'));
211
+ allFiles = result.files.filter((f) => isSlYamlPath(f));
186
212
  }
187
213
  catch (e) {
188
214
  const message = `Failed to list semantic-layer files under ${dir}: ${e instanceof Error ? e.message : String(e)}`;
@@ -283,7 +309,7 @@ export class SemanticLayerService {
283
309
  let allFiles;
284
310
  try {
285
311
  const listing = await this.configService.listFiles(dir);
286
- allFiles = listing.files.filter((f) => f.endsWith('.yaml'));
312
+ allFiles = listing.files.filter((f) => isSlYamlPath(f));
287
313
  }
288
314
  catch {
289
315
  return result;
@@ -350,7 +376,7 @@ export class SemanticLayerService {
350
376
  const schemaDir = `${SL_DIR_PREFIX}/${connectionId}/_schema`;
351
377
  try {
352
378
  const result = await this.configService.listFiles(schemaDir);
353
- const yamlFiles = result.files.filter((f) => f.endsWith('.yaml'));
379
+ const yamlFiles = result.files.filter((f) => isSlYamlPath(f));
354
380
  for (const filePath of yamlFiles) {
355
381
  try {
356
382
  const { content } = await this.configService.readFile(filePath);
@@ -390,7 +416,7 @@ export class SemanticLayerService {
390
416
  let yamlFiles;
391
417
  try {
392
418
  const result = await this.configService.listFiles(schemaDir);
393
- yamlFiles = result.files.filter((f) => f.endsWith('.yaml'));
419
+ yamlFiles = result.files.filter((f) => isSlYamlPath(f));
394
420
  }
395
421
  catch {
396
422
  return null;
@@ -457,7 +483,7 @@ export class SemanticLayerService {
457
483
  .filter((c) => !c.expr && !manifestColumns.has(c.name.toLowerCase()))
458
484
  .map((c) => c.name);
459
485
  if (absentDeclaredColumns.length > 0) {
460
- errors.push(`${source.name}.yaml: table "${source.table}" matched manifest ${manifestLabel}, ` +
486
+ errors.push(`${source.name}: table "${source.table}" matched manifest ${manifestLabel}, ` +
461
487
  `but declared column(s) absent from physical table: ${absentDeclaredColumns.join(', ')}. ` +
462
488
  `Available columns: ${[...manifestColumns.values()].join(', ')}`);
463
489
  }
@@ -466,7 +492,7 @@ export class SemanticLayerService {
466
492
  return !declared || (!declared.expr && !manifestColumns.has(grain.toLowerCase()));
467
493
  });
468
494
  if (missingGrainColumns.length > 0) {
469
- errors.push(`${source.name}.yaml: grain column(s) absent from physical table "${source.table}": ${missingGrainColumns.join(', ')}`);
495
+ errors.push(`${source.name}: grain column(s) absent from physical table "${source.table}": ${missingGrainColumns.join(', ')}`);
470
496
  }
471
497
  for (const column of declaredColumns) {
472
498
  if (!column.expr) {
@@ -480,7 +506,7 @@ export class SemanticLayerService {
480
506
  validMeasures: new Set(),
481
507
  });
482
508
  if (missing.length > 0) {
483
- errors.push(`${source.name}.yaml: computed column "${column.name}" references unknown column(s): ${missing.join(', ')}`);
509
+ errors.push(`${source.name}: computed column "${column.name}" references unknown column(s): ${missing.join(', ')}`);
484
510
  }
485
511
  }
486
512
  for (const segment of source.segments ?? []) {
@@ -492,7 +518,7 @@ export class SemanticLayerService {
492
518
  validMeasures: new Set(),
493
519
  });
494
520
  if (missing.length > 0) {
495
- errors.push(`${source.name}.yaml: segment "${segment.name}" references unknown column(s): ${missing.join(', ')}`);
521
+ errors.push(`${source.name}: segment "${segment.name}" references unknown column(s): ${missing.join(', ')}`);
496
522
  }
497
523
  }
498
524
  for (const measure of source.measures ?? []) {
@@ -504,7 +530,7 @@ export class SemanticLayerService {
504
530
  validMeasures: measureNames,
505
531
  });
506
532
  if (exprMissing.length > 0) {
507
- errors.push(`${source.name}.yaml: measure "${measure.name}" references unknown column(s): ${exprMissing.join(', ')}`);
533
+ errors.push(`${source.name}: measure "${measure.name}" references unknown column(s): ${exprMissing.join(', ')}`);
508
534
  }
509
535
  if (measure.filter) {
510
536
  const filterMissing = missingLocalExpressionRefs({
@@ -515,7 +541,7 @@ export class SemanticLayerService {
515
541
  validMeasures: new Set(),
516
542
  });
517
543
  if (filterMissing.length > 0) {
518
- errors.push(`${source.name}.yaml: measure "${measure.name}" filter references unknown column(s): ${filterMissing.join(', ')}`);
544
+ errors.push(`${source.name}: measure "${measure.name}" filter references unknown column(s): ${filterMissing.join(', ')}`);
519
545
  }
520
546
  }
521
547
  }
@@ -525,7 +551,7 @@ export class SemanticLayerService {
525
551
  continue;
526
552
  }
527
553
  if (!validOutputColumns.has(parsed.localColumn.toLowerCase())) {
528
- errors.push(`${source.name}.yaml: join to "${join.to}" references local column ` +
554
+ errors.push(`${source.name}: join to "${join.to}" references local column ` +
529
555
  `"${parsed.localColumn}" that is not a valid output column`);
530
556
  }
531
557
  const targetSource = sourcesByName.get(join.to.toLowerCase()) ??
@@ -533,7 +559,7 @@ export class SemanticLayerService {
533
559
  if (targetSource) {
534
560
  const targetColumns = new Set(targetSource.columns.map((c) => c.name.toLowerCase()));
535
561
  if (!targetColumns.has(parsed.targetColumn.toLowerCase())) {
536
- errors.push(`${source.name}.yaml: join to "${join.to}" references target column ` +
562
+ errors.push(`${source.name}: join to "${join.to}" references target column ` +
537
563
  `"${parsed.targetColumn}" that does not exist on the target source`);
538
564
  }
539
565
  }
@@ -548,41 +574,28 @@ export class SemanticLayerService {
548
574
  }
549
575
  return SemanticLayerService.mapDialect(connection.connectionType);
550
576
  }
551
- async listSourceNames(connectionId) {
552
- const dir = `${SL_DIR_PREFIX}/${connectionId}`;
553
- try {
554
- const result = await this.configService.listFiles(dir);
555
- return result.files.filter((f) => f.endsWith('.yaml')).map((f) => f.replace(`${dir}/`, '').replace('.yaml', ''));
556
- }
557
- catch {
558
- return [];
559
- }
560
- }
561
577
  async listFilesForConnection(connectionId) {
562
578
  const dir = `${SL_DIR_PREFIX}/${connectionId}`;
563
579
  try {
564
580
  const result = await this.configService.listFiles(dir, true);
565
- return result.files.filter((f) => f.endsWith('.yaml'));
581
+ return result.files.filter((f) => isSlYamlPath(f));
566
582
  }
567
583
  catch {
568
584
  return [];
569
585
  }
570
586
  }
571
- async readFileByPath(connectionId, relativePath) {
572
- const fullPath = `${SL_DIR_PREFIX}/${connectionId}/${relativePath}`;
573
- const result = await this.configService.readFile(fullPath);
574
- return {
575
- content: result.content,
576
- readOnly: relativePath.startsWith('_schema/'),
577
- };
578
- }
579
587
  async deleteSource(connectionId, sourceName, author, authorEmail) {
580
- const path = this.sourcePath(connectionId, sourceName);
581
- return this.configService.deleteFile(path, author, authorEmail, `Delete semantic layer source: ${sourceName}`);
582
- }
583
- async getSourceHistory(connectionId, sourceName) {
584
- const path = this.sourcePath(connectionId, sourceName);
585
- return this.configService.getFileHistory(path);
588
+ const file = await resolveSlSourceFile(this.configService, connectionId, sourceName);
589
+ if (!file) {
590
+ // `deleteFile` returns null for a missing path, which would let a no-op
591
+ // delete read as success. Distinguish the two real cases instead.
592
+ if (await this.isManifestBacked(connectionId, sourceName)) {
593
+ throw new Error(`Source '${sourceName}' is defined by the scan manifest (_schema/) and has no overlay file to delete. ` +
594
+ `Rescan the connection to remove it from the manifest.`);
595
+ }
596
+ throw new Error(`Semantic-layer source not found: ${connectionId}/${sourceName}`);
597
+ }
598
+ return this.configService.deleteFile(file.path, author, authorEmail, `Delete semantic layer source: ${sourceName}`);
586
599
  }
587
600
  /**
588
601
  * Validate the semantic layer state that *would* exist if `proposedSource`
@@ -639,6 +652,16 @@ export class SemanticLayerService {
639
652
  // any column-without-type errors via the warehouse probe.
640
653
  }
641
654
  merged.push(toPush);
655
+ // A join target the engine cannot resolve fails every downstream gate and
656
+ // query with the error attributed to the phantom target. Reject it here,
657
+ // on the source that declares it, while the writing agent can still fix it.
658
+ const missingJoinTargets = findMissingJoinTargets(toPush.joins, merged.map((s) => s.name));
659
+ const joinTargetErrors = missingJoinTargets.map((missing) => `${toPush.name}: ${formatMissingJoinTarget(missing)}. Declare joins only to existing ` +
660
+ `semantic-layer sources in this connection, or drop the join and keep the relationship ` +
661
+ `in a column description.`);
662
+ if (joinTargetErrors.length > 0) {
663
+ return { errors: [...loadErrors, ...joinTargetErrors], warnings: [], perSourceWarnings: {} };
664
+ }
642
665
  const validatable = merged.filter((s) => s.table != null || s.sql != null);
643
666
  if (validatable.length === 0) {
644
667
  return { errors: loadErrors, warnings: [], perSourceWarnings: {} };
@@ -704,7 +727,7 @@ export class SemanticLayerService {
704
727
  catch {
705
728
  return [];
706
729
  }
707
- const schemaFiles = files.filter((file) => /^semantic-layer\/[^/]+\/_schema\/.+\.ya?ml$/.test(file));
730
+ const schemaFiles = files.filter((file) => /^semantic-layer\/[^/]+\/_schema\//.test(file) && isSlYamlPath(file));
708
731
  const entries = [];
709
732
  for (const filePath of schemaFiles) {
710
733
  const connectionId = filePath.split('/')[1];
@@ -732,7 +755,7 @@ export class SemanticLayerService {
732
755
  let allFiles;
733
756
  try {
734
757
  const result = await this.configService.listFiles(dir);
735
- allFiles = result.files.filter((f) => f.endsWith('.yaml'));
758
+ allFiles = result.files.filter((f) => isSlYamlPath(f));
736
759
  }
737
760
  catch {
738
761
  return warnings;
@@ -883,7 +906,7 @@ export class SemanticLayerService {
883
906
  const tables = new Map();
884
907
  try {
885
908
  const result = await this.configService.listFiles(dir);
886
- const yamlFiles = result.files.filter((f) => f.endsWith('.yaml'));
909
+ const yamlFiles = result.files.filter((f) => isSlYamlPath(f));
887
910
  for (const filePath of yamlFiles) {
888
911
  try {
889
912
  const { content } = await this.configService.readFile(filePath);
@@ -1182,6 +1205,36 @@ function parseJoinColumns(on, sourceName, targetName) {
1182
1205
  }
1183
1206
  return { localColumn: left.column, targetColumn: right.column };
1184
1207
  }
1208
+ /**
1209
+ * Join targets that do not exactly match a known source name. The Python
1210
+ * engine resolves `joins[].to` by exact name within one connection's source
1211
+ * set (`engine._collect_orphan_join_target_errors`) and `query()` raises on a
1212
+ * miss, so anything looser here — case-insensitive matches, table refs,
1213
+ * sources in other connections — would pass this gate and then fail
1214
+ * query/validation as an orphan join target.
1215
+ */
1216
+ export function findMissingJoinTargets(joins, knownSourceNames) {
1217
+ const known = new Set();
1218
+ const canonicalByLower = new Map();
1219
+ for (const name of knownSourceNames) {
1220
+ known.add(name);
1221
+ canonicalByLower.set(name.toLowerCase(), name);
1222
+ }
1223
+ const missing = [];
1224
+ for (const join of joins ?? []) {
1225
+ if (known.has(join.to)) {
1226
+ continue;
1227
+ }
1228
+ missing.push({ to: join.to, caseMismatch: canonicalByLower.get(join.to.toLowerCase()) ?? null });
1229
+ }
1230
+ return missing;
1231
+ }
1232
+ export function formatMissingJoinTarget(missing) {
1233
+ const hint = missing.caseMismatch
1234
+ ? `; join targets are case-sensitive — the source is named "${missing.caseMismatch}"`
1235
+ : '';
1236
+ return `join target "${missing.to}" does not exist${hint}`;
1237
+ }
1185
1238
  /**
1186
1239
  * Returns one message per measure-level segment reference that doesn't resolve to
1187
1240
  * a segment defined on the source. Array is empty when every reference checks out.
@@ -0,0 +1,46 @@
1
+ import type { KtxFileStorePort } from '../../context/core/file-store.js';
2
+ export declare function assertSafeConnectionId(connectionId: string): string;
3
+ export declare function isSafeConnectionId(connectionId: string | undefined): connectionId is string;
4
+ export declare function sourceNameFromPath(path: string): string;
5
+ export declare function isSlYamlPath(path: string): boolean;
6
+ /**
7
+ * Derive the filename for a semantic-layer source. Total over all possible
8
+ * source names — never throws.
9
+ *
10
+ * Names that are already safe lowercase snake_case become `<name>.yaml`;
11
+ * anything else becomes `<slug>-<8 hex of sha256(name)>.yaml`. The two ranges
12
+ * are disjoint and the mapping is injective: safe filenames contain no `-`,
13
+ * hashed filenames always end in `-<8 hex>`, and slugs are lowercased so names
14
+ * differing only by case get distinct hashes instead of colliding paths on
15
+ * case-insensitive filesystems (macOS APFS, Windows).
16
+ *
17
+ * @internal
18
+ */
19
+ export declare function slSourceFileName(sourceName: string): string;
20
+ export declare function slSourceFilePath(connectionId: string, sourceName: string): string;
21
+ export interface SlSourceFile {
22
+ path: string;
23
+ content: string;
24
+ }
25
+ export declare function slSourceNameForFile(path: string, content: string): string;
26
+ /**
27
+ * The `name:` a semantic-layer YAML file declares, or null when the file is
28
+ * nameless or so broken even the name is unrecoverable. Null is how
29
+ * `writeSource` tells a genuine name conflict at a derived path apart from the
30
+ * broken remains of the source being written, which a rewrite must repair
31
+ * rather than refuse.
32
+ *
33
+ * Uses `parseDocument`, not `parse`: a file with a syntax error below the
34
+ * `name:` line still parses into a partial tree whose top-level `name:` is
35
+ * intact. `parse` would throw on the same input and drop the source to its
36
+ * filename — wrong for human-renamed files, whose filename is not the name.
37
+ */
38
+ export declare function slDeclaredSourceName(content: string): string | null;
39
+ /**
40
+ * Find the standalone/overlay file that defines `sourceName` for a connection.
41
+ * Returns null when no file declares the name (the source may still exist as a
42
+ * manifest entry under `_schema/`). Throws when more than one file declares the
43
+ * same name — that breaks the one-file-per-name invariant and must be repaired
44
+ * by hand rather than silently picking one.
45
+ */
46
+ export declare function resolveSlSourceFile(fileStore: Pick<KtxFileStorePort, 'listFiles' | 'readFile'>, connectionId: string, sourceName: string): Promise<SlSourceFile | null>;
@@ -0,0 +1,131 @@
1
+ import { createHash } from 'node:crypto';
2
+ import YAML from 'yaml';
3
+ // Semantic-layer source identity lives in the file's `name:` field, which mirrors
4
+ // the warehouse identifier verbatim (Snowflake's uppercase `SIGNED_UP`, `EVENT$LOG`).
5
+ // The filename is a derived label and never participates in identity: reads resolve
6
+ // a source by scanning the connection directory and matching `name:`, and writes
7
+ // reuse the resolved file's path, so files can be freely renamed by humans without
8
+ // changing which source they define.
9
+ function assertSafePathToken(kind, value) {
10
+ if (value.trim().length === 0 ||
11
+ value.includes('..') ||
12
+ value.includes('\\') ||
13
+ value.startsWith('/') ||
14
+ value.startsWith('.') ||
15
+ value.includes('//')) {
16
+ throw new Error(`Unsafe ${kind}: ${value}`);
17
+ }
18
+ return value;
19
+ }
20
+ export function assertSafeConnectionId(connectionId) {
21
+ if (!isSafeConnectionId(connectionId)) {
22
+ throw new Error(`Unsafe connection id: ${connectionId}`);
23
+ }
24
+ return assertSafePathToken('connection id', connectionId);
25
+ }
26
+ export function isSafeConnectionId(connectionId) {
27
+ return typeof connectionId === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId);
28
+ }
29
+ export function sourceNameFromPath(path) {
30
+ return (path
31
+ .split('/')
32
+ .at(-1)
33
+ ?.replace(/\.ya?ml$/, '') ?? path);
34
+ }
35
+ // The one predicate for "this path is a semantic-layer YAML file". ktx itself
36
+ // always writes `.yaml` (see `slSourceFileName`), but humans rename freely and
37
+ // the dbt ecosystem's habit is `.yml`, so every reader must accept both — a
38
+ // listing that recognizes only one extension makes the same file visible to
39
+ // some entry points and invisible to others.
40
+ export function isSlYamlPath(path) {
41
+ return path.endsWith('.yaml') || path.endsWith('.yml');
42
+ }
43
+ // Windows refuses these basenames regardless of extension — a genuinely universal
44
+ // filesystem invariant, so the static list is acceptable.
45
+ const WINDOWS_RESERVED_BASENAME = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$/;
46
+ const SAFE_FILE_BASENAME = /^[a-z0-9][a-z0-9_]{0,63}$/;
47
+ /**
48
+ * Derive the filename for a semantic-layer source. Total over all possible
49
+ * source names — never throws.
50
+ *
51
+ * Names that are already safe lowercase snake_case become `<name>.yaml`;
52
+ * anything else becomes `<slug>-<8 hex of sha256(name)>.yaml`. The two ranges
53
+ * are disjoint and the mapping is injective: safe filenames contain no `-`,
54
+ * hashed filenames always end in `-<8 hex>`, and slugs are lowercased so names
55
+ * differing only by case get distinct hashes instead of colliding paths on
56
+ * case-insensitive filesystems (macOS APFS, Windows).
57
+ *
58
+ * @internal
59
+ */
60
+ export function slSourceFileName(sourceName) {
61
+ if (SAFE_FILE_BASENAME.test(sourceName) && !WINDOWS_RESERVED_BASENAME.test(sourceName)) {
62
+ return `${sourceName}.yaml`;
63
+ }
64
+ const slug = sourceName
65
+ .toLowerCase()
66
+ .replace(/[^a-z0-9_]+/g, '_')
67
+ .replace(/_+/g, '_')
68
+ .replace(/^_+|_+$/g, '')
69
+ .slice(0, 64);
70
+ const hash = createHash('sha256').update(sourceName, 'utf-8').digest('hex').slice(0, 8);
71
+ return `${slug || 'src'}-${hash}.yaml`;
72
+ }
73
+ export function slSourceFilePath(connectionId, sourceName) {
74
+ return `semantic-layer/${assertSafeConnectionId(connectionId)}/${slSourceFileName(sourceName)}`;
75
+ }
76
+ // Same keying as `loadLocalSlSourceRecords`: the in-file `name:` is the identity;
77
+ // the filename is only a fallback for files so broken that even the `name:` is
78
+ // unrecoverable, or genuinely nameless ones. A file left mid-edit with a syntax
79
+ // error below its `name:` line keeps its declared identity (see
80
+ // `slDeclaredSourceName`), so a human-renamed source is still addressed by name
81
+ // while broken instead of silently reverting to its filename.
82
+ export function slSourceNameForFile(path, content) {
83
+ return slDeclaredSourceName(content) ?? sourceNameFromPath(path);
84
+ }
85
+ /**
86
+ * The `name:` a semantic-layer YAML file declares, or null when the file is
87
+ * nameless or so broken even the name is unrecoverable. Null is how
88
+ * `writeSource` tells a genuine name conflict at a derived path apart from the
89
+ * broken remains of the source being written, which a rewrite must repair
90
+ * rather than refuse.
91
+ *
92
+ * Uses `parseDocument`, not `parse`: a file with a syntax error below the
93
+ * `name:` line still parses into a partial tree whose top-level `name:` is
94
+ * intact. `parse` would throw on the same input and drop the source to its
95
+ * filename — wrong for human-renamed files, whose filename is not the name.
96
+ */
97
+ export function slDeclaredSourceName(content) {
98
+ let doc;
99
+ try {
100
+ doc = YAML.parseDocument(content);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ const name = doc.get('name');
106
+ return typeof name === 'string' && name.length > 0 ? name : null;
107
+ }
108
+ /**
109
+ * Find the standalone/overlay file that defines `sourceName` for a connection.
110
+ * Returns null when no file declares the name (the source may still exist as a
111
+ * manifest entry under `_schema/`). Throws when more than one file declares the
112
+ * same name — that breaks the one-file-per-name invariant and must be repaired
113
+ * by hand rather than silently picking one.
114
+ */
115
+ export async function resolveSlSourceFile(fileStore, connectionId, sourceName) {
116
+ const dir = `semantic-layer/${assertSafeConnectionId(connectionId)}`;
117
+ const schemaDir = `${dir}/_schema`;
118
+ const listed = await fileStore.listFiles(dir);
119
+ const paths = listed.files.filter((file) => isSlYamlPath(file) && !file.startsWith(`${schemaDir}/`)).sort();
120
+ const matches = [];
121
+ for (const path of paths) {
122
+ const raw = await fileStore.readFile(path);
123
+ if (slSourceNameForFile(path, raw.content) === sourceName) {
124
+ matches.push({ path, content: raw.content });
125
+ }
126
+ }
127
+ if (matches.length > 1) {
128
+ throw new Error(`Multiple semantic-layer files declare source "${sourceName}": ${matches.map((match) => match.path).join(', ')}`);
129
+ }
130
+ return matches[0] ?? null;
131
+ }
@@ -1,4 +1,4 @@
1
- import type { ZodType } from 'zod';
1
+ import type { z } from 'zod';
2
2
  import type { GitAuthorResolverPort } from '../../../context/tools/authors.js';
3
3
  import type { ToolContext, ToolOutput } from '../../../context/tools/base-tool.js';
4
4
  import { BaseTool } from '../../../context/tools/base-tool.js';
@@ -21,7 +21,7 @@ export interface BaseSemanticLayerToolDeps {
21
21
  slSearchService: SlSearchService;
22
22
  authorResolver: GitAuthorResolverPort;
23
23
  }
24
- export declare abstract class BaseSemanticLayerTool<TInput extends ZodType = ZodType> extends BaseTool<TInput> {
24
+ export declare abstract class BaseSemanticLayerTool<TInput extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>> extends BaseTool<TInput> {
25
25
  protected readonly semanticLayerService: SemanticLayerService;
26
26
  protected readonly slSearchService: SlSearchService;
27
27
  protected readonly authorResolver: GitAuthorResolverPort;
@@ -14,13 +14,8 @@ export class BaseSemanticLayerTool extends BaseTool {
14
14
  }
15
15
  async readSourceYaml(connectionId, sourceName, context) {
16
16
  const semanticLayerService = context?.session?.semanticLayerService ?? this.semanticLayerService;
17
- try {
18
- const { content } = await semanticLayerService.readSourceFile(connectionId, sourceName);
19
- return content;
20
- }
21
- catch {
22
- return null;
23
- }
17
+ const file = await semanticLayerService.readSourceFile(connectionId, sourceName);
18
+ return file?.content ?? null;
24
19
  }
25
20
  buildMarkdown(success, errors, sourceName, extra) {
26
21
  const parts = [];
@@ -91,14 +91,8 @@ If no source exists yet, use sl_write_source instead — this tool will reject t
91
91
  }
92
92
  }
93
93
  // Read existing source
94
- let currentYaml = null;
95
- try {
96
- const { content } = await semanticLayerService.readSourceFile(connectionId, sourceName);
97
- currentYaml = content;
98
- }
99
- catch {
100
- currentYaml = null;
101
- }
94
+ const currentFile = await semanticLayerService.readSourceFile(connectionId, sourceName);
95
+ const currentYaml = currentFile?.content ?? null;
102
96
  if (!currentYaml) {
103
97
  const manifestBacked = await semanticLayerService.isManifestBacked(connectionId, sourceName);
104
98
  if (manifestBacked) {
@@ -138,6 +132,14 @@ If no source exists yet, use sl_write_source instead — this tool will reject t
138
132
  catch (e) {
139
133
  return this.buildOutput(false, [`YAML parse error after edits: ${e}`], sourceName);
140
134
  }
135
+ // The in-file `name:` is the source's identity — an edited name would make
136
+ // writeSource create a second source instead of updating this one.
137
+ if (source.name !== sourceName) {
138
+ return this.buildOutput(false, [
139
+ `Edits change "name:" from "${sourceName}" to "${source.name ?? '<missing>'}" — renaming is not supported. ` +
140
+ `Delete the source and recreate it under the new name.`,
141
+ ], sourceName);
142
+ }
141
143
  source = normalizeSemanticLayerDescriptions(source, { fillMissing: !!context.session?.ingest });
142
144
  // Re-serialize and write
143
145
  const updatedYaml = YAML.stringify(source, { indent: 2, lineWidth: 0, version: '1.1' });