refactor: enforce ktx naming and AGENTS.md compliance sweep (#289)

Align the tree with AGENTS.md/CLAUDE.md conventions:

- Rewrite user-facing strings, docs, and tests to lowercase `ktx`
  (no bare uppercase `KTX` tokens remain outside literal identifiers).
- Drop the legacy `historicSql` migration path and its now-unused
  helpers, per the no-backward-compat rule.
- Remove `as unknown as` / `any` casts: narrow `BaseTool` generics to
  `z.ZodObject`, add a typed `createLookerClient`, and delete the dead
  `getParametersSchema`/`toAnthropicFormat` pre-AI-SDK helpers.
- Use `InvalidArgumentError` for Commander parse failures.
- Finish the adapter→connector prose conversion in the `ktx.yaml` docs
  while keeping the literal `adapters` config key.
This commit is contained in:
Andrey Avtomonov 2026-06-11 13:49:45 +02:00 committed by GitHub
parent 005c5fc860
commit 00cdf2de90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
237 changed files with 844 additions and 974 deletions

View file

@ -88,13 +88,18 @@ const defaultLogger: LookerClientLogger = {
class InlineLookerSettings extends NodeSettings {
constructor(private readonly params: LookerConnectionParams) {
super('', {
// @looker/sdk-rtl boundary: NodeSettings consumes a string-valued config
// section (read back via the readConfig override below), but its constructor
// is typed to accept a fully-realized IApiSettings. The string record is the
// shape the library actually reads, so narrow to IApiSection first.
const settings: IApiSection = {
base_url: normalizeBaseUrl(params.base_url),
client_id: params.client_id,
client_secret: params.client_secret, // pragma: allowlist secret
verify_ssl: 'true',
timeout: '120',
} as unknown as IApiSettings);
};
super('', settings as IApiSection & IApiSettings);
}
override readConfig(_section?: string): IApiSection {

View file

@ -19,6 +19,16 @@ export class DefaultLookerConnectionClientFactory implements LookerConnectionCli
) {}
async createClient(lookerConnectionId: string): Promise<LookerRuntimeClient> {
return this.createLookerClient(lookerConnectionId);
}
/**
* Like {@link createClient} but preserves the concrete {@link LookerClient}
* type, so callers that need methods outside the `LookerRuntimeClient`
* contract (e.g. `listLookerConnections`, `testConnection`) keep them without
* a cast.
*/
async createLookerClient(lookerConnectionId: string): Promise<LookerClient> {
const credentials = await this.resolver.resolve(lookerConnectionId);
return new LookerClient(credentials, this.deps);
}

View file

@ -214,7 +214,7 @@ export function validateLookerMappings(args: {
if (!args.knownKtxConnectionIds.has(mapping.ktxConnectionId)) {
errors.push({
key: mapping.lookerConnectionName,
reason: `KTX connection ${mapping.ktxConnectionId} does not exist`,
reason: `ktx connection ${mapping.ktxConnectionId} does not exist`,
});
continue;
}

View file

@ -81,7 +81,7 @@ class MetabaseApiError extends Error {
* Strip Metabase `[[ ... {{ var }} ... ]]` optional-clause blocks from native SQL.
*
* The bracketed blocks are emitted only when the embedded `{{ var }}` is supplied at
* Metabase query time. For KTX semantic-layer ingest there's no such runtime
* Metabase query time. For ktx semantic-layer ingest there's no such runtime
* parameter chat-time filters are composed by the SL query planner so the optional
* block must be removed before the SQL becomes a permanent SL source. Substituting a
* dummy value (the alternative) bakes a placeholder filter into the source and silently

View file

@ -34,7 +34,7 @@ export function metabaseRuntimeConfigFromLocalConnection(
}
if (hasNetworkProxy(connection)) {
throw new Error(
`Standalone KTX does not support proxy-bearing Metabase connections yet. Use hosted Metabase ingest for "${connectionId}" until the KTX Metabase proxy support spec lands.`,
`Standalone ktx does not support proxy-bearing Metabase connections yet. Use hosted Metabase ingest for "${connectionId}" until the ktx Metabase proxy support spec lands.`,
);
}

View file

@ -186,7 +186,7 @@ export function validateMetabaseMappings(args: {
continue;
}
if (!args.knownKtxConnectionIds.has(connectionId)) {
errors.push({ key, reason: `KTX connection ${connectionId} does not exist` });
errors.push({ key, reason: `ktx connection ${connectionId} does not exist` });
}
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
@ -207,7 +207,7 @@ export function validateMappingPhysicalMatch(
}
if (target.connection_type !== expectedType) {
return `Metabase database engine '${engine}' does not match KTX connection type '${target.connection_type}'`;
return `Metabase database engine '${engine}' does not match ktx connection type '${target.connection_type}'`;
}
const metabaseDb = normalizeName(mapping.metabaseDbName);
@ -215,7 +215,7 @@ export function validateMappingPhysicalMatch(
if (engine === 'snowflake' || engine === 'bigquery' || engine === 'bigquery-cloud-sdk') {
if (metabaseDb && targetDb && metabaseDb !== targetDb) {
return `Metabase database '${mapping.metabaseDbName}' does not match KTX connection database '${displayValue(
return `Metabase database '${mapping.metabaseDbName}' does not match ktx connection database '${displayValue(
getTargetDatabase(target),
)}'`;
}
@ -227,12 +227,12 @@ export function validateMappingPhysicalMatch(
const targetHost = normalizeHost(target.host);
if (metabaseHost && targetHost && metabaseHost !== targetHost) {
return `Metabase host '${mapping.metabaseHost}' does not match KTX connection host '${displayValue(
return `Metabase host '${mapping.metabaseHost}' does not match ktx connection host '${displayValue(
target.host,
)}'`;
}
if (metabaseDb && targetDb && metabaseDb !== targetDb) {
return `Metabase database '${mapping.metabaseDbName}' does not match KTX connection database '${displayValue(
return `Metabase database '${mapping.metabaseDbName}' does not match ktx connection database '${displayValue(
getTargetDatabase(target),
)}'`;
}
@ -274,7 +274,7 @@ export async function refreshMetabaseMapping(args: {
if (!target) {
physicalMismatches.push({
mappingId: String(mapping.id),
reason: `KTX connection ${mapping.ktxConnectionId} does not exist`,
reason: `ktx connection ${mapping.ktxConnectionId} does not exist`,
});
continue;
}

View file

@ -62,7 +62,7 @@ async function readOptionalFile(path: string): Promise<{ exists: boolean; conten
function buildGateRepairSystemPrompt(): string {
return `<role>
You repair one KTX isolated-diff artifact gate failure inside the integration worktree.
You repair one ktx isolated-diff artifact gate failure inside the integration worktree.
</role>
<rules>

View file

@ -65,7 +65,7 @@ async function readOptionalFile(path: string): Promise<{ exists: boolean; conten
function buildResolverSystemPrompt(): string {
return `<role>
You repair one failed KTX isolated-diff patch inside the integration worktree.
You repair one failed ktx isolated-diff patch inside the integration worktree.
</role>
<rules>

View file

@ -77,7 +77,7 @@ import type { SourceAdapter } from './types.js';
const promptsDir = fileURLToPath(new URL('../../prompts', import.meta.url));
const skillsDir = fileURLToPath(new URL('../../skills', import.meta.url));
const LOCAL_AUTHOR = { name: 'KTX Local', email: 'local@ktx.local' };
const LOCAL_AUTHOR = { name: 'ktx Local', email: 'local@ktx.local' };
const LOCAL_SHAPE_WARNING = 'Local ingest validates semantic-layer YAML shape only.';
const INGEST_TRACE_LEVELS = new Set<IngestTraceLevel>(['error', 'info', 'debug', 'trace']);
@ -232,8 +232,9 @@ class LocalSlPythonPort implements SlPythonPort {
}
class LocalShapeOnlySlValidator implements SlValidatorPort<SlValidationDeps> {
private validateParsedSource(sourceName: string, parsed: Record<string, unknown>) {
const isOverlay = parsed.table == null && parsed.sql == null;
private validateParsedSource(sourceName: string, parsed: unknown) {
const fields = (parsed ?? {}) as { table?: unknown; sql?: unknown };
const isOverlay = fields.table == null && fields.sql == null;
const result = (isOverlay ? sourceOverlaySchema : sourceDefinitionSchema).safeParse(parsed);
return result.success
? { errors: [], warnings: [LOCAL_SHAPE_WARNING] }
@ -255,7 +256,7 @@ class LocalShapeOnlySlValidator implements SlValidatorPort<SlValidationDeps> {
const { sources, loadErrors } = await deps.semanticLayerService.loadAllSources(connectionId);
const source = sources.find((candidate) => candidate.name === sourceName);
if (source) {
return this.validateParsedSource(sourceName, source as unknown as Record<string, unknown>);
return this.validateParsedSource(sourceName, source);
}
const detail =
loadErrors.length > 0
@ -279,7 +280,7 @@ class LocalShapeOnlySlValidator implements SlValidatorPort<SlValidationDeps> {
}
try {
const parsed = YAML.parse(file.content) as unknown as Record<string, unknown>;
const parsed = YAML.parse(file.content) as Record<string, unknown>;
return this.validateParsedSource(sourceName, parsed);
} catch (error) {
return {

View file

@ -513,7 +513,7 @@ export function buildMemoryFlowViewModel(input: MemoryFlowReplayInput): MemoryFl
: `${input.connectionId}/${input.adapter}`;
return {
title: `KTX memory flow ${titleSources} ${input.status}`,
title: `ktx memory flow ${titleSources} ${input.status}`,
subtitle: `Run ${input.runId} Sync ${input.syncId}`,
status: input.status,
activeLine: activeLine(input),

View file

@ -70,10 +70,10 @@ export class DiscoverDataTool extends BaseTool<typeof discoverDataInputSchema> {
}
if (input.sourceName) {
const sl = await this.deps.slDiscoverTool.call(
const sl = (await this.deps.slDiscoverTool.call(
{ sourceName: input.sourceName, connectionId: input.connectionId },
context,
);
)) as ToolOutput;
return { markdown: sl.markdown, structured: { wiki: null, sl: sl.structured, raw: null } };
}
@ -85,17 +85,17 @@ export class DiscoverDataTool extends BaseTool<typeof discoverDataInputSchema> {
let raw: DiscoverDataStructured['raw'] = null;
if (query) {
const wikiResult = await this.deps.wikiSearchTool.call({ query, limit }, context);
const wikiResult = (await this.deps.wikiSearchTool.call({ query, limit }, context)) as ToolOutput;
if (totalFound(wikiResult.structured) > 0) {
parts.push('## Wiki Pages', '> use `wiki_read(blockKey)` for full content', wikiResult.markdown, '');
wiki = wikiResult.structured;
}
}
const slResult = await this.deps.slDiscoverTool.call(
const slResult = (await this.deps.slDiscoverTool.call(
{ query: query || undefined, connectionId: input.connectionId },
context,
);
)) as ToolOutput;
if (totalSources(slResult.structured) > 0) {
parts.push(
'## Semantic Layer Sources',

View file

@ -239,7 +239,7 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), input.abortSignal, () => generateText(request));
input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: toLlmTokenUsage(result.totalUsage ?? result.usage) });
if (typeof result.text !== 'string') {
throw new Error('KTX LLM text generation returned no text');
throw new Error('ktx LLM text generation returned no text');
}
return result.text;
}
@ -271,12 +271,12 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
}),
}
: {}),
output: Output.object({ schema: input.schema as unknown as FlexibleSchema<TOutput> }),
output: Output.object({ schema: input.schema as FlexibleSchema<TOutput> }),
};
const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), input.abortSignal, () => generateText(request));
input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: toLlmTokenUsage(result.totalUsage ?? result.usage) });
if (result.output == null) {
throw new Error('KTX LLM object generation returned no output');
throw new Error('ktx LLM object generation returned no output');
}
return result.output as TOutput;
}

View file

@ -193,7 +193,7 @@ function isClaudeRateLimitResult(result: SDKResultMessage, rejectedSignal: RateL
}
function claudeRateLimitSignal(message: SDKMessage): RateLimitSignal | null {
const record = message as unknown as Record<string, unknown>;
const record = message as Record<string, unknown>;
if (record.type === 'rate_limit_event') {
const info = record.rate_limit_info as Record<string, unknown> | undefined;
if (!info) return null;
@ -253,7 +253,7 @@ function baseOptions(input: {
? { behavior: 'allow', toolUseID: options.toolUseID }
: {
behavior: 'deny',
message: `KTX claude-code runtime only permits current KTX MCP tools; denied ${toolName}.`,
message: `ktx claude-code runtime only permits current ktx MCP tools; denied ${toolName}.`,
toolUseID: options.toolUseID,
},
permissionMode: 'dontAsk',

View file

@ -198,7 +198,7 @@ export function resolveLocalKtxEmbeddingConfig(
batchSize: config.batchSize,
};
}
throw new Error(`Unsupported KTX embedding backend: ${String((config as { backend?: string }).backend)}`);
throw new Error(`Unsupported ktx embedding backend: ${String((config as { backend?: string }).backend)}`);
}
/** @internal */

View file

@ -28,7 +28,7 @@ export function normalizeKtxRuntimeToolOutput(value: unknown): KtxRuntimeToolOut
function assertObjectSchema(name: string, schema: z.ZodType): asserts schema is z.ZodObject<z.ZodRawShape> {
if (!(schema instanceof z.ZodObject)) {
throw new Error(`KTX runtime tool "${name}" must use z.object input schema for claude-code`);
throw new Error(`ktx runtime tool "${name}" must use z.object input schema for claude-code`);
}
}
@ -75,7 +75,7 @@ export function createRuntimeToolDescriptorFromAiTool(name: string, aiSdkTool: T
inputSchema: aiSdkTool.inputSchema as KtxRuntimeToolDescriptor['inputSchema'],
execute: async (input) => {
if (typeof aiSdkTool.execute !== 'function') {
throw new Error(`KTX runtime tool "${name}" has no execute function`);
throw new Error(`ktx runtime tool "${name}" has no execute function`);
}
return normalizeKtxRuntimeToolOutput(
await aiSdkTool.execute(input as never, { toolCallId: `runtime-${name}` } as never),

View file

@ -56,12 +56,12 @@ const toolAnnotations = {
const toolDescriptions = {
connection_list:
'List configured read-only data connections available to this KTX project. Use this before connection-scoped tools when the project may have multiple warehouses.',
'List configured read-only data connections available to this ktx project. Use this before connection-scoped tools when the project may have multiple warehouses.',
discover_data:
'Search across KTX wiki pages, semantic-layer sources, measures, dimensions, raw tables, and columns. Example: discover_data({ query: "monthly orders by customer", connectionId: "warehouse", kinds: ["sl_source", "table"] }).',
'Search across ktx wiki pages, semantic-layer sources, measures, dimensions, raw tables, and columns. Example: discover_data({ query: "monthly orders by customer", connectionId: "warehouse", kinds: ["sl_source", "table"] }).',
wiki_search:
'Search KTX wiki pages for reusable business context. Example: wiki_search({ query: "revenue recognition", limit: 5 }).',
wiki_read: 'Read a KTX wiki page by key returned from wiki_search. Example: wiki_read({ key: "global/revenue" }).',
'Search ktx wiki pages for reusable business context. Example: wiki_search({ query: "revenue recognition", limit: 5 }).',
wiki_read: 'Read a ktx wiki page by key returned from wiki_search. Example: wiki_read({ key: "global/revenue" }).',
entity_details:
'Read table and column metadata from the latest live-database scan snapshot. Example: entity_details({ connectionId: "warehouse", entities: [{ table: { catalog: null, db: "public", name: "orders" }, columns: ["id"] }] }).',
dictionary_search:
@ -71,9 +71,9 @@ const toolDescriptions = {
sl_query:
'Execute a semantic-layer query and return headers, rows, and total row count, plus correctness notes (e.g. compile-only or fan-out) when relevant. The generated SQL and full query plan are omitted by default; request them with include: ["sql"] and/or include: ["plan"]. Example: sl_query({ connectionId: "warehouse", measures: ["orders.order_count"], dimensions: [{ field: "orders.created_at", granularity: "month" }], include: ["sql"] }).',
sql_execution:
'Execute one parser-validated read-only SQL query against a configured KTX connection. Example: sql_execution({ connectionId: "warehouse", sql: "select count(*) from public.orders", maxRows: 100 }).',
'Execute one parser-validated read-only SQL query against a configured ktx connection. Example: sql_execution({ connectionId: "warehouse", sql: "select count(*) from public.orders", maxRows: 100 }).',
memory_ingest:
'Ingest free-form markdown knowledge into durable KTX memory. Use this for business rules, metric definitions, schema gotchas, recurring findings, or explicit user requests to remember something. Example: memory_ingest({ connectionId: "warehouse", content: "ARR is reported in cents in this warehouse." }).',
'Ingest free-form markdown knowledge into durable ktx memory. Use this for business rules, metric definitions, schema gotchas, recurring findings, or explicit user requests to remember something. Example: memory_ingest({ connectionId: "warehouse", content: "ARR is reported in cents in this warehouse." }).',
memory_ingest_status:
'Read the current or final status for a memory ingest run. Example: memory_ingest_status({ runId: "memory-run-1" }).',
} satisfies Record<string, string>;
@ -856,7 +856,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
const ingestInput: MemoryAgentInput = {
userId: userContext.userId,
chatId: `mcp-${randomUUID()}`,
userMessage: 'Ingest external knowledge into KTX memory.',
userMessage: 'Ingest external knowledge into ktx memory.',
assistantMessage: input.content,
connectionId: input.connectionId,
sourceType: 'external_ingest',

View file

@ -52,7 +52,7 @@ import type {
const promptsDir = fileURLToPath(new URL('../../prompts', import.meta.url));
const skillsDir = fileURLToPath(new URL('../../skills', import.meta.url));
const LOCAL_AUTHOR = { name: 'KTX Local', email: 'local@ktx.local' };
const LOCAL_AUTHOR = { name: 'ktx Local', email: 'local@ktx.local' };
const LOCAL_SHAPE_WARNING = 'Local memory ingest validates semantic-layer YAML shape only.';
export interface CreateLocalProjectMemoryIngestOptions {

View file

@ -62,7 +62,7 @@ const llmSchema = z
models: z
.partialRecord(z.enum(KTX_MODEL_ROLES), z.string().min(1))
.default({})
.describe('Per-role model overrides keyed by KTX model role (e.g. "default", "triage"). Values are provider-specific model identifiers.'),
.describe('Per-role model overrides keyed by ktx model role (e.g. "default", "triage"). Values are provider-specific model identifiers.'),
promptCaching: promptCachingSchema.optional().describe('Optional prompt-caching tunables.'),
})
.describe('LLM provider, per-role model overrides, and prompt-caching tunables.');
@ -243,14 +243,14 @@ const storageSchema = z
state: z
.enum(KTX_STORAGE_STATES)
.default('sqlite')
.describe('Backend for KTX state storage. "sqlite" uses .ktx/db.sqlite; "postgres" expects a configured Postgres connection.'),
.describe('Backend for ktx state storage. "sqlite" uses .ktx/db.sqlite; "postgres" expects a configured Postgres connection.'),
search: z
.enum(KTX_SEARCH_BACKENDS)
.default('sqlite-fts5')
.describe('Backend for search indexes. "sqlite-fts5" uses SQLite FTS5; "postgres-hybrid" uses Postgres lexical + vector hybrid search.'),
git: storageGitSchema.prefault({}).describe('Git-backed storage commit policy.'),
})
.describe('Storage backends and commit policy for KTX state and search indexes.');
.describe('Storage backends and commit policy for ktx state and search indexes.');
const connectionSchema = connectionConfigSchema;
@ -282,13 +282,13 @@ const ktxProjectConfigSchema = z
.record(z.string(), connectionSchema)
.default({})
.describe('Map of connection ID to connector configuration. Keys are user-chosen names referenced elsewhere in the config.'),
storage: storageSchema.prefault({}).describe('Storage backends and commit policy for KTX state and search indexes.'),
storage: storageSchema.prefault({}).describe('Storage backends and commit policy for ktx state and search indexes.'),
llm: llmSchema.prefault({}).describe('LLM provider, per-role model overrides, and prompt-caching tunables.'),
ingest: ingestSchema.prefault({}).describe('Ingest pipeline configuration.'),
agent: agentSchema.prefault({}).describe('Agent feature configuration.'),
scan: scanSchema.prefault({}).describe('Schema-scan configuration: enrichment and relationship discovery.'),
})
.describe('Configuration schema for KTX project files (ktx.yaml).');
.describe('Configuration schema for ktx project files (ktx.yaml).');
export type KtxProjectConfig = z.infer<typeof ktxProjectConfigSchema>;
export type KtxProjectLlmConfig = z.infer<typeof llmSchema>;

View file

@ -102,7 +102,7 @@ const lookerConnectionSchema = z
.min(1)
.optional()
.describe('Reference to Looker OAuth client secret (e.g. env:LOOKER_CLIENT_SECRET).'),
mappings: lookerMappingsSchema.optional().describe('Looker connection-name to KTX warehouse mappings.'),
mappings: lookerMappingsSchema.optional().describe('Looker connection-name to ktx warehouse mappings.'),
})
.describe('Looker context-source connection.');

View file

@ -15,7 +15,7 @@ export const metabaseMappingsSchema = z
databaseMappings: z
.record(z.string(), stringTargetSchema)
.default({})
.describe('Map of Metabase database ID (positive integer string) to KTX connection ID. Use null to explicitly unmap.'),
.describe('Map of Metabase database ID (positive integer string) to ktx connection ID. Use null to explicitly unmap.'),
syncEnabled: z
.record(z.string(), z.boolean())
.default({})
@ -38,7 +38,7 @@ export const lookerMappingsSchema = z
connectionMappings: z
.record(z.string().min(1), stringTargetSchema)
.default({})
.describe('Map of Looker connection name to KTX connection ID. Use null to explicitly unmap.'),
.describe('Map of Looker connection name to ktx connection ID. Use null to explicitly unmap.'),
})
.describe('Looker connection-to-warehouse mapping configuration.');

View file

@ -123,7 +123,7 @@ export async function initKtxProject(options: InitKtxProjectOptions): Promise<In
const commit = await runtime.git.commitFiles(
['ktx.yaml', ...TRACKED_SCAFFOLD_FILES.map((file) => file.path)],
`Initialize KTX project: ${projectName}`,
`Initialize ktx project: ${projectName}`,
authorName,
authorEmail,
);

View file

@ -569,7 +569,7 @@ export class KtxDescriptionGenerator {
if (!connector.sampleTable) {
fallbackReason = 'capability_missing';
this.logger?.warn('KTX scan connector does not support table sampling; falling back to metadata-only prompt', {
this.logger?.warn('ktx scan connector does not support table sampling; falling back to metadata-only prompt', {
connectorId: input.connector.id,
table: input.table.name,
});
@ -690,7 +690,7 @@ export class KtxDescriptionGenerator {
let fallbackReason: 'capability_missing' | 'sampling_failed' | 'empty_sample' | null = null;
if (!input.connector.sampleTable) {
fallbackReason = 'capability_missing';
this.logger?.warn('KTX scan connector does not support table sampling; falling back to metadata-only prompt', {
this.logger?.warn('ktx scan connector does not support table sampling; falling back to metadata-only prompt', {
connectorId: input.connector.id,
table: input.table.name,
});
@ -846,7 +846,7 @@ export class KtxDescriptionGenerator {
}
if (!input.connector.sampleTable) {
this.logger?.warn('KTX scan connector does not support table sampling for data-source description generation', {
this.logger?.warn('ktx scan connector does not support table sampling for data-source description generation', {
connectorId: input.connector.id,
});
return 'No accessible tables found in database';
@ -927,7 +927,7 @@ export class KtxDescriptionGenerator {
let columnValues = column.sampleValues;
if (!columnValues || columnValues.length === 0) {
if (!input.connector.sampleColumn) {
this.logger?.warn('KTX scan connector does not support column sampling; using available metadata only', {
this.logger?.warn('ktx scan connector does not support column sampling; using available metadata only', {
connectorId: input.connector.id,
table: input.table.name,
column: column.name,

View file

@ -154,7 +154,7 @@ function scanReportPath(connectionId: string, syncId: string): string {
function assertSupportedMode(mode: KtxScanMode): void {
if (mode !== 'structural' && mode !== 'relationships' && mode !== 'enriched') {
throw new Error(`Unsupported KTX scan mode: ${mode}`);
throw new Error(`Unsupported ktx scan mode: ${mode}`);
}
}
@ -544,7 +544,7 @@ export async function runLocalScan(options: RunLocalScanOptions): Promise<LocalS
}
report.warnings.push({
code: 'enrichment_failed',
message: `KTX scan enrichment failed after structural scan completed: ${message}`,
message: `ktx scan enrichment failed after structural scan completed: ${message}`,
recoverable: true,
metadata: { mode, detectRelationships: options.detectRelationships ?? false },
});

View file

@ -55,7 +55,7 @@ function parseWarning(rawWarning: unknown, path: string): KtxScanWarning {
typeof rawWarning.message !== 'string' ||
typeof rawWarning.recoverable !== 'boolean'
) {
throw new Error(`Invalid KTX schema warning artifact: ${path}`);
throw new Error(`Invalid ktx schema warning artifact: ${path}`);
}
return {
code: rawWarning.code as KtxScanWarning['code'],
@ -73,7 +73,7 @@ async function readWarnings(input: ReadLocalScanStructuralSnapshotInput): Promis
const warningRaw = await input.project.fileStore.readFile(path);
const parsed = JSON.parse(warningRaw.content) as unknown;
if (!isRecord(parsed) || !Array.isArray(parsed.warnings)) {
throw new Error(`Invalid KTX schema warnings artifact: ${path}`);
throw new Error(`Invalid ktx schema warnings artifact: ${path}`);
}
return parsed.warnings.map((warning) => parseWarning(warning, path));
} catch (error) {
@ -102,7 +102,7 @@ function parseColumn(rawColumn: unknown, path: string): KtxSchemaColumn {
rawColumn.dimensionType !== 'number' &&
rawColumn.dimensionType !== 'boolean')
) {
throw new Error(`Invalid KTX schema column artifact: ${path}`);
throw new Error(`Invalid ktx schema column artifact: ${path}`);
}
return {
name: rawColumn.name,
@ -122,7 +122,7 @@ function parseForeignKey(rawForeignKey: unknown, path: string): KtxSchemaForeign
typeof rawForeignKey.toTable !== 'string' ||
typeof rawForeignKey.toColumn !== 'string'
) {
throw new Error(`Invalid KTX schema foreign key artifact: ${path}`);
throw new Error(`Invalid ktx schema foreign key artifact: ${path}`);
}
return {
fromColumn: rawForeignKey.fromColumn,
@ -137,7 +137,7 @@ function parseForeignKey(rawForeignKey: unknown, path: string): KtxSchemaForeign
function parseTable(raw: string, path: string): KtxSchemaTable {
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed) || typeof parsed.name !== 'string' || !Array.isArray(parsed.columns)) {
throw new Error(`Invalid KTX schema table artifact: ${path}`);
throw new Error(`Invalid ktx schema table artifact: ${path}`);
}
return {
catalog: optionalStringOrNull(parsed.catalog) ?? null,

View file

@ -317,7 +317,7 @@ function compositeSkipBlocks(report: KtxRelationshipBenchmarkReport): string[] {
export function formatKtxRelationshipBenchmarkReportMarkdown(report: KtxRelationshipBenchmarkReport): string {
const lines = [
'# KTX Relationship Discovery Benchmark Evidence',
'# ktx Relationship Discovery Benchmark Evidence',
'',
`Generated: ${report.generatedAt}`,
'',

View file

@ -153,7 +153,7 @@ async function detectCompositeRelationships(input: {
} catch (error) {
input.warnings.push({
code: 'relationship_validation_failed',
message: `KTX composite relationship detection failed: ${error instanceof Error ? error.message : String(error)}`,
message: `ktx composite relationship detection failed: ${error instanceof Error ? error.message : String(error)}`,
recoverable: true,
metadata: { source: 'composite_relationship_detection' },
});
@ -185,7 +185,7 @@ function sqlExecutor(input: DiscoverKtxRelationshipsInput): {
warnings: [
{
code: 'connector_capability_missing',
message: 'KTX scan connector cannot run read-only SQL relationship validation',
message: 'ktx scan connector cannot run read-only SQL relationship validation',
recoverable: true,
metadata: { capability: 'readOnlySql' },
},
@ -199,7 +199,7 @@ function sqlExecutor(input: DiscoverKtxRelationshipsInput): {
warnings: [
{
code: 'relationship_validation_failed',
message: 'KTX scan connector advertises readOnlySql but does not expose executeReadOnly',
message: 'ktx scan connector advertises readOnlySql but does not expose executeReadOnly',
recoverable: true,
metadata: { capability: 'readOnlySql' },
},

View file

@ -182,7 +182,7 @@ function mapValidProposals(
const toColumn = toTable ? findColumn(toTable, item.toColumn) : null;
if (!fromTable || !toTable || !fromColumn || !toColumn) {
warnings.push(
invalidReferenceWarning('KTX relationship LLM proposal referenced a table or column that is not in the schema.', {
invalidReferenceWarning('ktx relationship LLM proposal referenced a table or column that is not in the schema.', {
proposal: item,
}),
);
@ -218,7 +218,7 @@ function generationFailureWarning(error: unknown): KtxScanWarning {
const message = error instanceof Error ? error.message : String(error);
return {
code: 'relationship_llm_proposal_failed',
message: `KTX relationship LLM proposal failed: ${message}`,
message: `ktx relationship LLM proposal failed: ${message}`,
recoverable: true,
};
}
@ -233,7 +233,7 @@ export async function proposeKtxRelationshipCandidatesWithLlm(
const settings = mergeSettings(input.settings);
const evidence = buildEvidencePacket(input.schema, input.profile, settings);
const system = [
'You are helping KTX review possible SQL relationships before validation.',
'You are helping ktx review possible SQL relationships before validation.',
'Use only the compact schema evidence. Propose likely primary keys and foreign keys for later SQL validation.',
'Return structured output only; never assume a join is accepted.',
].join('\n');

View file

@ -115,7 +115,7 @@ export class SemanticLayerService {
async listConnectionIds(): Promise<string[]> {
try {
const result = await this.configService.listFiles(SL_DIR_PREFIX);
// Directories under semantic-layer/ are connectionIds. Local KTX projects use
// Directories under semantic-layer/ are connectionIds. Local ktx projects use
// readable ids like "warehouse" and "dbt-main", not only UUIDs.
return result.files
.map((f) => f.replace(`${SL_DIR_PREFIX}/`, '').split('/')[0])

View file

@ -1,4 +1,4 @@
import type { ZodType } from 'zod';
import type { z } from 'zod';
import type { GitAuthorResolverPort } from '../../../context/tools/authors.js';
import type { ToolContext, ToolOutput } from '../../../context/tools/base-tool.js';
import { BaseTool } from '../../../context/tools/base-tool.js';
@ -27,7 +27,9 @@ export interface BaseSemanticLayerToolDeps {
// ── Abstract base class ──
export abstract class BaseSemanticLayerTool<TInput extends ZodType = ZodType> extends BaseTool<TInput> {
export abstract class BaseSemanticLayerTool<
TInput extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>,
> extends BaseTool<TInput> {
protected readonly semanticLayerService: SemanticLayerService;
protected readonly slSearchService: SlSearchService;
protected readonly authorResolver: GitAuthorResolverPort;

View file

@ -1,5 +1,5 @@
import { tool } from 'ai';
import { z, type ZodType } from 'zod';
import { tool, type Tool } from 'ai';
import { z } from 'zod';
import { noopLogger, type KtxLogger } from '../../context/core/config.js';
import type { KtxRuntimeToolDescriptor } from '../llm/runtime-port.js';
import { normalizeKtxRuntimeToolOutput } from '../llm/runtime-tools.js';
@ -73,7 +73,7 @@ interface MethodologyEntry {
/**
* SECURITY: All tools require authentication. userId must always be provided in ToolContext.
*/
export abstract class BaseTool<TInput extends ZodType = ZodType> {
export abstract class BaseTool<TInput extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>> {
protected readonly logger: KtxLogger;
abstract readonly name: string;
@ -86,37 +86,9 @@ export abstract class BaseTool<TInput extends ZodType = ZodType> {
abstract get inputSchema(): TInput;
abstract call(input: z.infer<TInput>, context: ToolContext): Promise<any>;
abstract call(input: z.infer<TInput>, context: ToolContext): Promise<unknown>;
getParametersSchema(): {
type: 'object';
properties: Record<string, any>;
required?: string[];
} {
const jsonSchema = z.toJSONSchema(this.inputSchema, {
target: 'draft-7',
});
return jsonSchema as any;
}
toAnthropicFormat(): {
name: string;
description: string;
input_schema: {
type: 'object';
properties: Record<string, any>;
required?: string[];
};
} {
return {
name: this.name,
description: this.description,
input_schema: this.getParametersSchema(),
};
}
toAiSdkTool(context: ToolContext): any {
toAiSdkTool(context: ToolContext): Tool {
const toolName = this.name;
const logger = this.logger;
@ -137,7 +109,7 @@ export abstract class BaseTool<TInput extends ZodType = ZodType> {
if (!callContext.userId) {
throw new Error('Authentication required: userId must be provided in ToolContext');
}
const parsedInput = this.parseInput(params as Record<string, any>);
const parsedInput = this.parseInput(params as Record<string, unknown>);
const result = await this.call(parsedInput, callContext);
return result;
} catch (error) {
@ -171,19 +143,19 @@ export abstract class BaseTool<TInput extends ZodType = ZodType> {
return {
name: toolName,
description: this.description,
inputSchema: this.inputSchema as unknown as KtxRuntimeToolDescriptor['inputSchema'],
inputSchema: this.inputSchema,
execute: async (params) => {
const callContext = { ...context };
if (!callContext.userId) {
throw new Error('Authentication required: userId must be provided in ToolContext');
}
const parsedInput = this.parseInput(params as Record<string, any>);
const parsedInput = this.parseInput(params as Record<string, unknown>);
return normalizeKtxRuntimeToolOutput(await this.call(parsedInput, callContext));
},
};
}
parseInput(input: Record<string, any>): z.infer<TInput> {
parseInput(input: Record<string, unknown>): z.infer<TInput> {
return this.inputSchema.parse(input);
}